From 50f1b8a8dc61dd1221ff24b3058cbbb9765499a1 Mon Sep 17 00:00:00 2001 From: Lasse Rune Hansen Date: Sun, 14 Jun 2026 12:42:15 +0200 Subject: [PATCH] feat(backend): Implement mandatory authentication and admin module - Add Role property to User entity with migration - Create BootstrapController for first admin user creation - Remove [AllowAnonymous] from all learning content controllers - Create AdminController with admin-only endpoints - Create AdminService for user management - Create UserReportService for progress reports - Add UserRepository implementation - Update AuthService with role support feat(frontend): Implement authentication system - Add AuthStore with React context for auth state management - Create Login, Register, Landing, and Home pages - Add ProtectedRoute and AdminRoute components - Create Auth API types and client - Configure Vite with @/ path alias - Add comprehensive CSS styles for auth and landing pages BREAKING CHANGE: All learning content now requires authentication. Users must register and sign in before accessing lessons, quizzes, and stories. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .../Application/DTOs/Admin/AdminUserDto.cs | 89 +++ .../Application/Interfaces/IAdminService.cs | 53 ++ .../Application/Interfaces/IAuthService.cs | 14 + .../Interfaces/IUserReportService.cs | 51 ++ .../Application/Services/AdminService.cs | 196 +++++ .../Application/Services/UserReportService.cs | 189 +++++ GermanApp/Domain/Entities/User.cs | 25 + .../Data/DbContext/AppDbContext.cs | 1 + .../20260614101057_AddRoleToUser.Designer.cs | 676 ++++++++++++++++++ .../20260614101057_AddRoleToUser.cs | 30 + .../Migrations/AppDbContextModelSnapshot.cs | 7 + .../Data/Repositories/UserRepository.cs | 59 ++ .../Infrastructure/Services/AuthService.cs | 62 +- .../Controllers/AdminController.cs | 269 +++++++ .../Controllers/BootstrapController.cs | 76 ++ .../Controllers/LessonsController.cs | 8 - .../Controllers/LevelsController.cs | 5 - .../Controllers/QuizQuestionsController.cs | 14 - .../Controllers/QuizzesController.cs | 17 - GermanApp/Program.cs | 6 + german-app-frontend/.env.development | 2 + german-app-frontend/src/App.tsx | 117 ++- .../features/auth/ProtectedRoute.tsx | 66 ++ german-app-frontend/src/index.css | 453 ++++++++++++ german-app-frontend/src/lib/api/auth.ts | 152 ++++ german-app-frontend/src/pages/HomePage.tsx | 105 +++ german-app-frontend/src/pages/LandingPage.tsx | 85 +++ german-app-frontend/src/pages/LoginPage.tsx | 100 +++ .../src/pages/RegisterPage.tsx | 150 ++++ german-app-frontend/src/stores/authStore.tsx | 256 +++++++ german-app-frontend/src/types/api/auth.ts | 62 ++ german-app-frontend/tsconfig.app.json | 9 +- german-app-frontend/vite.config.ts | 6 + 33 files changed, 3327 insertions(+), 83 deletions(-) create mode 100644 GermanApp/Application/DTOs/Admin/AdminUserDto.cs create mode 100644 GermanApp/Application/Interfaces/IAdminService.cs create mode 100644 GermanApp/Application/Interfaces/IUserReportService.cs create mode 100644 GermanApp/Application/Services/AdminService.cs create mode 100644 GermanApp/Application/Services/UserReportService.cs create mode 100644 GermanApp/Infrastructure/Data/Migrations/20260614101057_AddRoleToUser.Designer.cs create mode 100644 GermanApp/Infrastructure/Data/Migrations/20260614101057_AddRoleToUser.cs create mode 100644 GermanApp/Infrastructure/Data/Repositories/UserRepository.cs create mode 100644 GermanApp/Presentation/Controllers/AdminController.cs create mode 100644 GermanApp/Presentation/Controllers/BootstrapController.cs create mode 100644 german-app-frontend/.env.development create mode 100644 german-app-frontend/src/components/features/auth/ProtectedRoute.tsx create mode 100644 german-app-frontend/src/lib/api/auth.ts create mode 100644 german-app-frontend/src/pages/HomePage.tsx create mode 100644 german-app-frontend/src/pages/LandingPage.tsx create mode 100644 german-app-frontend/src/pages/LoginPage.tsx create mode 100644 german-app-frontend/src/pages/RegisterPage.tsx create mode 100644 german-app-frontend/src/stores/authStore.tsx create mode 100644 german-app-frontend/src/types/api/auth.ts diff --git a/GermanApp/Application/DTOs/Admin/AdminUserDto.cs b/GermanApp/Application/DTOs/Admin/AdminUserDto.cs new file mode 100644 index 0000000..d8e94c0 --- /dev/null +++ b/GermanApp/Application/DTOs/Admin/AdminUserDto.cs @@ -0,0 +1,89 @@ +namespace GermanApp.Application.DTOs; + +/// +/// DTO for admin user information. +/// +public record AdminUserDto( + int Id, + string Username, + string Email, + string Role, + string CurrentLevel, + int Streak, + int TotalPoints, + DateTime CreatedAt); + +/// +/// DTO for user progress report. +/// +public record UserProgressReportDto( + int UserId, + string Username, + string Email, + string CurrentLevel, + int TotalLessonsCompleted, + int TotalQuizzesCompleted, + double AverageQuizScore, + int TotalPoints, + int CurrentStreak, + DateTime LastActivityDate); + +/// +/// DTO for lesson completion status. +/// +public record LessonCompletionDto( + int LessonId, + string LessonTitle, + string LevelCode, + bool IsCompleted, + DateTime? CompletedAt); + +/// +/// DTO for user quiz result (for admin reports). +/// +public record UserQuizResultDto( + int QuizId, + string QuizTitle, + int Score, + int PassingScore, + bool Passed, + DateTime AttemptDate); + +/// +/// DTO for creating a new story via admin. +/// +public record AdminCreateStoryDto( + int LevelId, + string Theme, + int SegmentCount = 3, + bool GenerateAudio = false); + +/// +/// DTO for admin dashboard statistics. +/// +public record AdminDashboardStatsDto( + int TotalUsers, + int TotalLessons, + int TotalQuizzes, + int TotalStorySegments, + int ActiveUsersThisWeek, + double AverageUserProgress); + +/// +/// DTO for user list item (for admin user management). +/// +public record AdminUserListItemDto( + int Id, + string Username, + string Email, + string Role, + string CurrentLevel, + int TotalPoints, + int Streak, + DateTime CreatedAt); + +/// +/// DTO for updating user role (admin only). +/// +public record UpdateUserRoleDto( + string Role); diff --git a/GermanApp/Application/Interfaces/IAdminService.cs b/GermanApp/Application/Interfaces/IAdminService.cs new file mode 100644 index 0000000..4067a6d --- /dev/null +++ b/GermanApp/Application/Interfaces/IAdminService.cs @@ -0,0 +1,53 @@ +using GermanApp.Application.DTOs; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace GermanApp.Application.Interfaces; + +/// +/// Interface for admin services. +/// Part of the Application layer. +/// +public interface IAdminService +{ + /// + /// Gets all users for admin view. + /// + /// Cancellation token + /// List of all users + Task> GetAllUsersAsync(CancellationToken cancellationToken = default); + + /// + /// Gets a specific user by ID. + /// + /// The user ID + /// Cancellation token + /// The user DTO + Task GetUserByIdAsync(int userId, CancellationToken cancellationToken = default); + + /// + /// Updates a user's role. + /// + /// The user ID + /// The new role + /// Cancellation token + /// The updated user DTO + Task UpdateUserRoleAsync(int userId, string newRole, CancellationToken cancellationToken = default); + + /// + /// Deletes a user (admin only, cannot delete self). + /// + /// The user ID to delete + /// The admin user ID making the request + /// Cancellation token + /// True if successful + Task DeleteUserAsync(int userId, int adminUserId, CancellationToken cancellationToken = default); + + /// + /// Gets admin dashboard statistics. + /// + /// Cancellation token + /// Dashboard statistics + Task GetDashboardStatsAsync(CancellationToken cancellationToken = default); +} diff --git a/GermanApp/Application/Interfaces/IAuthService.cs b/GermanApp/Application/Interfaces/IAuthService.cs index 6e54439..4f70112 100644 --- a/GermanApp/Application/Interfaces/IAuthService.cs +++ b/GermanApp/Application/Interfaces/IAuthService.cs @@ -42,4 +42,18 @@ public interface IAuthService /// /// The refresh token to revoke Task RevokeRefreshTokenAsync(string refreshToken); + + /// + /// Creates the first admin user (bootstrap). + /// This is a special method that creates an admin user without requiring authentication. + /// + /// Admin user registration data + /// Authentication response with token + Task CreateAdminUserAsync(RegisterDto registerDto); + + /// + /// Checks if an admin user already exists. + /// + /// True if admin user exists, false otherwise + Task AdminUserExistsAsync(); } diff --git a/GermanApp/Application/Interfaces/IUserReportService.cs b/GermanApp/Application/Interfaces/IUserReportService.cs new file mode 100644 index 0000000..3a68c5f --- /dev/null +++ b/GermanApp/Application/Interfaces/IUserReportService.cs @@ -0,0 +1,51 @@ +using GermanApp.Application.DTOs; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace GermanApp.Application.Interfaces; + +/// +/// Interface for user progress report services. +/// Part of the Application layer. +/// +public interface IUserReportService +{ + /// + /// Generates a progress report for a specific user. + /// + /// The user ID + /// Cancellation token + /// User progress report + Task GenerateUserReportAsync(int userId, CancellationToken cancellationToken = default); + + /// + /// Generates progress reports for all users. + /// + /// Cancellation token + /// List of all user progress reports + Task> GenerateAllUserReportsAsync(CancellationToken cancellationToken = default); + + /// + /// Gets lesson completion data for a user. + /// + /// The user ID + /// Cancellation token + /// List of lesson completion data + Task> GetUserLessonProgressAsync(int userId, CancellationToken cancellationToken = default); + + /// + /// Gets quiz results for a user. + /// + /// The user ID + /// Cancellation token + /// List of quiz results + Task> GetUserQuizResultsAsync(int userId, CancellationToken cancellationToken = default); + + /// + /// Exports user reports as CSV. + /// + /// Cancellation token + /// CSV content as string + Task ExportReportsAsCsvAsync(CancellationToken cancellationToken = default); +} diff --git a/GermanApp/Application/Services/AdminService.cs b/GermanApp/Application/Services/AdminService.cs new file mode 100644 index 0000000..b7dbc2d --- /dev/null +++ b/GermanApp/Application/Services/AdminService.cs @@ -0,0 +1,196 @@ +using GermanApp.Application.DTOs; +using GermanApp.Application.Interfaces; +using GermanApp.Domain.Entities; +using GermanApp.Domain.Interfaces; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace GermanApp.Application.Services; + +/// +/// Service for admin operations. +/// Part of the Application layer. +/// +public class AdminService : IAdminService +{ + private readonly IRepository _userRepository; + private readonly ILevelRepository _levelRepository; + private readonly ILessonRepository _lessonRepository; + private readonly IQuizRepository _quizRepository; + private readonly IStoryRepository _storyRepository; + private readonly IUserProgressRepository _userProgressRepository; + + public AdminService( + IRepository userRepository, + ILevelRepository levelRepository, + ILessonRepository lessonRepository, + IQuizRepository quizRepository, + IStoryRepository storyRepository, + IUserProgressRepository userProgressRepository) + { + _userRepository = userRepository; + _levelRepository = levelRepository; + _lessonRepository = lessonRepository; + _quizRepository = quizRepository; + _storyRepository = storyRepository; + _userProgressRepository = userProgressRepository; + } + + /// + /// Gets all users for admin view. + /// + /// Cancellation token + /// List of all users + public async Task> GetAllUsersAsync(CancellationToken cancellationToken = default) + { + var users = await _userRepository.GetAllAsync(cancellationToken); + + return users.Select(u => new AdminUserListItemDto( + u.Id, + u.Username, + u.Email, + u.Role, + u.CurrentLevel, + u.TotalPoints, + u.Streak, + u.CreatedAt)) + .OrderBy(u => u.Username) + .ToList(); + } + + /// + /// Gets a specific user by ID. + /// + /// The user ID + /// Cancellation token + /// The user DTO + public async Task GetUserByIdAsync(int userId, CancellationToken cancellationToken = default) + { + var user = await _userRepository.GetByIdAsync(userId, cancellationToken); + + if (user == null) + return null; + + return new AdminUserDto( + user.Id, + user.Username, + user.Email, + user.Role, + user.CurrentLevel, + user.Streak, + user.TotalPoints, + user.CreatedAt); + } + + /// + /// Updates a user's role. + /// + /// The user ID + /// The new role + /// Cancellation token + /// The updated user DTO + public async Task UpdateUserRoleAsync(int userId, string newRole, CancellationToken cancellationToken = default) + { + var user = await _userRepository.GetByIdAsync(userId, cancellationToken); + + if (user == null) + return null; + + // Validate role and update using domain method + user.SetRole(newRole); + + await _userRepository.UpdateAsync(user, cancellationToken); + + return new AdminUserDto( + user.Id, + user.Username, + user.Email, + user.Role, + user.CurrentLevel, + user.Streak, + user.TotalPoints, + user.CreatedAt); + } + + /// + /// Deletes a user (admin only, cannot delete self). + /// + /// The user ID to delete + /// The admin user ID making the request + /// Cancellation token + /// True if successful + public async Task DeleteUserAsync(int userId, int adminUserId, CancellationToken cancellationToken = default) + { + // Cannot delete self + if (userId == adminUserId) + throw new InvalidOperationException("Cannot delete your own user account."); + + // Check if user exists + var user = await _userRepository.GetByIdAsync(userId, cancellationToken); + if (user == null) + return false; + + // Prevent deleting the last admin + var allUsers = await _userRepository.GetAllAsync(cancellationToken); + var adminCount = allUsers.Count(u => u.IsAdmin()); + if (user.IsAdmin() && adminCount <= 1) + throw new InvalidOperationException("Cannot delete the last admin user."); + + await _userRepository.DeleteAsync(user, cancellationToken); + return true; + } + + /// + /// Gets admin dashboard statistics. + /// + /// Cancellation token + /// Dashboard statistics + public async Task GetDashboardStatsAsync(CancellationToken cancellationToken = default) + { + var usersTask = _userRepository.GetAllAsync(cancellationToken); + var lessonsTask = _lessonRepository.GetAllAsync(cancellationToken); + var quizzesTask = _quizRepository.GetAllAsync(cancellationToken); + var userProgressTask = _userProgressRepository.GetAllAsync(cancellationToken); + + await Task.WhenAll(usersTask, lessonsTask, quizzesTask, userProgressTask); + + var users = await usersTask; + var lessons = await lessonsTask; + var quizzes = await quizzesTask; + var userProgress = await userProgressTask; + + // Get all levels and count story segments + var levels = await _levelRepository.GetAllAsync(cancellationToken); + var storyCount = 0; + foreach (var level in levels) + { + var segments = await _storyRepository.GetByLevelAsync(level.Id, true, cancellationToken); + storyCount += segments.Count; + } + + // Calculate active users this week + var oneWeekAgo = DateTime.UtcNow.AddDays(-7); + var activeUsersThisWeek = userProgress + .Where(up => up.LastAttemptDate >= oneWeekAgo) + .Select(up => up.UserId) + .Distinct() + .Count(); + + // Calculate average user progress + var completedLessons = userProgress.Count(up => up.IsCompleted); + var totalPossibleProgress = users.Count * lessons.Count; + var averageProgress = totalPossibleProgress > 0 + ? (double)completedLessons / totalPossibleProgress * 100 + : 0; + + return new AdminDashboardStatsDto( + users.Count, + lessons.Count, + quizzes.Count, + storyCount, + activeUsersThisWeek, + averageProgress); + } +} diff --git a/GermanApp/Application/Services/UserReportService.cs b/GermanApp/Application/Services/UserReportService.cs new file mode 100644 index 0000000..1f0b2c7 --- /dev/null +++ b/GermanApp/Application/Services/UserReportService.cs @@ -0,0 +1,189 @@ +using GermanApp.Application.DTOs; +using GermanApp.Application.Interfaces; +using GermanApp.Domain.Entities; +using GermanApp.Domain.Interfaces; +using Microsoft.EntityFrameworkCore; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace GermanApp.Application.Services; + +/// +/// Service for generating user progress reports. +/// Part of the Application layer. +/// +public class UserReportService : IUserReportService +{ + private readonly IRepository _userRepository; + private readonly IUserProgressRepository _userProgressRepository; + private readonly ILessonRepository _lessonRepository; + private readonly ILevelRepository _levelRepository; + private readonly IQuizRepository _quizRepository; + private readonly IStoryProgressRepository _storyProgressRepository; + + public UserReportService( + IRepository userRepository, + IUserProgressRepository userProgressRepository, + ILessonRepository lessonRepository, + ILevelRepository levelRepository, + IQuizRepository quizRepository, + IStoryProgressRepository storyProgressRepository) + { + _userRepository = userRepository; + _userProgressRepository = userProgressRepository; + _lessonRepository = lessonRepository; + _levelRepository = levelRepository; + _quizRepository = quizRepository; + _storyProgressRepository = storyProgressRepository; + } + + /// + /// Generates a progress report for a specific user. + /// + /// The user ID + /// Cancellation token + /// User progress report + public async Task GenerateUserReportAsync(int userId, CancellationToken cancellationToken = default) + { + var user = await _userRepository.GetByIdAsync(userId, cancellationToken); + if (user == null) + throw new ArgumentException("User not found", nameof(userId)); + + var userProgress = await _userProgressRepository.GetByUserAsync(userId, cancellationToken); + var allLessons = await _lessonRepository.GetAllAsync(cancellationToken); + + var totalLessonsCompleted = userProgress.Count(up => up.IsCompleted); + var totalQuizzesCompleted = userProgress.Count(up => up.IsCompleted && up.QuizScore > 0); + var averageQuizScore = userProgress.Where(up => up.QuizScore > 0).Select(up => up.QuizScore).DefaultIfEmpty().Average(); + + // Find last activity date + var lastActivityDate = userProgress + .OrderByDescending(up => up.LastAttemptDate) + .FirstOrDefault()?.LastAttemptDate ?? user.CreatedAt; + + return new UserProgressReportDto( + user.Id, + user.Username, + user.Email, + user.CurrentLevel, + totalLessonsCompleted, + totalQuizzesCompleted, + averageQuizScore, + user.TotalPoints, + user.Streak, + lastActivityDate); + } + + /// + /// Generates progress reports for all users. + /// + /// Cancellation token + /// List of all user progress reports + public async Task> GenerateAllUserReportsAsync(CancellationToken cancellationToken = default) + { + var users = await _userRepository.GetAllAsync(cancellationToken); + var reports = new List(); + + foreach (var user in users) + { + var report = await GenerateUserReportAsync(user.Id, cancellationToken); + reports.Add(report); + } + + return reports; + } + + /// + /// Gets lesson completion data for a user. + /// + /// The user ID + /// Cancellation token + /// List of lesson completion data + public async Task> GetUserLessonProgressAsync(int userId, CancellationToken cancellationToken = default) + { + var userProgress = await _userProgressRepository.GetByUserAsync(userId, cancellationToken); + var allLessons = await _lessonRepository.GetAllAsync(cancellationToken); + var allLevels = await _levelRepository.GetAllAsync(cancellationToken); + + var levelDict = allLevels.ToDictionary(l => l.Id, l => l.Code); + + return userProgress + .Select(up => new LessonCompletionDto( + up.LessonId, + up.Lesson?.Title ?? "Unknown", + up.Lesson?.LevelId != null && levelDict.TryGetValue(up.Lesson.LevelId, out var levelCode) ? levelCode : "Unknown", + up.IsCompleted, + up.IsCompleted ? up.LastAttemptDate : null)) + .OrderBy(l => l.LevelCode) + .ThenBy(l => l.LessonTitle) + .ToList(); + } + + /// + /// Gets quiz results for a user. + /// + /// The user ID + /// Cancellation token + /// List of quiz results + public async Task> GetUserQuizResultsAsync(int userId, CancellationToken cancellationToken = default) + { + var userProgress = await _userProgressRepository.GetByUserAsync(userId, cancellationToken); + + return userProgress + .Where(up => up.QuizScore > 0) + .Select(up => new UserQuizResultDto( + up.Lesson?.Id ?? 0, // Using LessonId as QuizId proxy (simplified) + up.Lesson?.Title ?? "Unknown Quiz", + up.QuizScore, + 80, // Default passing score + up.QuizScore >= 80, + up.LastAttemptDate)) + .OrderByDescending(r => r.AttemptDate) + .ToList(); + } + + /// + /// Exports user reports as CSV. + /// + /// Cancellation token + /// CSV content as string + public async Task ExportReportsAsCsvAsync(CancellationToken cancellationToken = default) + { + var reports = await GenerateAllUserReportsAsync(cancellationToken); + var sb = new StringBuilder(); + + // Header row + sb.AppendLine("User ID,Username,Email,Current Level,Lessons Completed,Quizzes Completed,Average Score,Total Points,Streak,Last Activity"); + + // Data rows + foreach (var report in reports) + { + sb.AppendLine($"{EscapeCsv(report.UserId.ToString())},{EscapeCsv(report.Username)},{EscapeCsv(report.Email)},{EscapeCsv(report.CurrentLevel)},{report.TotalLessonsCompleted},{report.TotalQuizzesCompleted},{report.AverageQuizScore:F2},{report.TotalPoints},{report.CurrentStreak},{EscapeCsv(report.LastActivityDate.ToString("yyyy-MM-dd HH:mm:ss"))}"); + } + + return sb.ToString(); + } + + /// + /// Escapes a value for CSV output. + /// + /// The value to escape + /// Escaped value + private static string EscapeCsv(string value) + { + if (value == null) + return string.Empty; + + // If the value contains commas, quotes, or newlines, wrap it in quotes and escape quotes + if (value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r")) + { + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + return value; + } +} diff --git a/GermanApp/Domain/Entities/User.cs b/GermanApp/Domain/Entities/User.cs index c1f0f9d..94a9f9a 100644 --- a/GermanApp/Domain/Entities/User.cs +++ b/GermanApp/Domain/Entities/User.cs @@ -10,6 +10,7 @@ public class User public string Email { get; private set; } = string.Empty; public string PasswordHash { get; private set; } = string.Empty; public string CurrentLevel { get; private set; } = "A1"; + public string Role { get; private set; } = "User"; // Default role is "User", "Admin" for administrators public int Streak { get; private set; } public int TotalPoints { get; private set; } public DateTime CreatedAt { get; private set; } @@ -76,6 +77,30 @@ public class User PasswordHash = newPasswordHash; } + /// + /// Assigns admin role to user (bootstrap only). + /// + public void AssignAdminRole() + { + Role = "Admin"; + } + + /// + /// Sets the user's role. + /// + /// The role to assign (User or Admin) + public void SetRole(string role) + { + if (role != "Admin" && role != "User") + throw new ArgumentException("Role must be 'Admin' or 'User'.", nameof(role)); + Role = role; + } + + /// + /// Checks if user has admin role. + /// + public bool IsAdmin() => Role == "Admin"; + // Navigation properties public virtual ICollection StoryProgress { get; private set; } = new List(); } diff --git a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs index 10fd970..67882ee 100644 --- a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs +++ b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs @@ -110,6 +110,7 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext builder.Property(u => u.Email).IsRequired().HasMaxLength(100); builder.Property(u => u.PasswordHash).IsRequired().HasMaxLength(255); builder.Property(u => u.CurrentLevel).HasMaxLength(10).HasDefaultValue("A1"); + builder.Property(u => u.Role).HasMaxLength(20).HasDefaultValue("User"); builder.Property(u => u.Streak).HasDefaultValue(0); builder.Property(u => u.TotalPoints).HasDefaultValue(0); builder.Property(u => u.CreatedAt).IsRequired(); diff --git a/GermanApp/Infrastructure/Data/Migrations/20260614101057_AddRoleToUser.Designer.cs b/GermanApp/Infrastructure/Data/Migrations/20260614101057_AddRoleToUser.Designer.cs new file mode 100644 index 0000000..6865d33 --- /dev/null +++ b/GermanApp/Infrastructure/Data/Migrations/20260614101057_AddRoleToUser.Designer.cs @@ -0,0 +1,676 @@ +// +using System; +using GermanApp.Infrastructure.Data.DbContext; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GermanApp.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260614101057_AddRoleToUser")] + partial class AddRoleToUser + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("LevelId") + .HasColumnType("integer"); + + b.Property("LevelId1") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Topic") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("LevelId1"); + + b.HasIndex("LevelId", "Order") + .IsUnique(); + + b.ToTable("Lessons"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.Level", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Order") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Order") + .IsUnique(); + + b.ToTable("Levels"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("LessonId") + .HasColumnType("integer"); + + b.Property("PassingScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(80); + + b.Property("ShuffleQuestions") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("TimeLimitMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("LessonId"); + + b.ToTable("Quizzes"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsCorrect") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Order") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("QuizQuestionId") + .HasColumnType("integer"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("QuizQuestionId", "Order") + .IsUnique(); + + b.ToTable("QuizOptions"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CorrectAnswer") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Difficulty") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Order") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("Points") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("QuizId") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("QuizId", "Order") + .IsUnique(); + + b.ToTable("QuizQuestions"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.StoryProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCompleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LevelId") + .HasColumnType("integer"); + + b.Property("StorySegmentId") + .HasColumnType("integer"); + + b.Property("UnlockedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("UserId1") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LevelId"); + + b.HasIndex("StorySegmentId"); + + b.HasIndex("UserId1"); + + b.HasIndex("UserId", "StorySegmentId") + .IsUnique(); + + b.ToTable("StoryProgress"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.StorySegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AudioUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedReadingMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(2); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("LessonId") + .HasColumnType("integer"); + + b.Property("LessonId1") + .HasColumnType("integer"); + + b.Property("LevelId") + .HasColumnType("integer"); + + b.Property("LevelId1") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Theme") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("LessonId"); + + b.HasIndex("LessonId1"); + + b.HasIndex("LevelId1"); + + b.HasIndex("LevelId", "Order") + .IsUnique(); + + b.ToTable("StorySegments"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentLevel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("A1"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("User"); + + b.Property("Streak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("TotalPoints") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsCompleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LastAttemptDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LessonId") + .HasColumnType("integer"); + + b.Property("QuizScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LessonId"); + + b.HasIndex("UserId", "LessonId") + .IsUnique(); + + b.ToTable("UserProgress"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b => + { + b.HasOne("GermanApp.Domain.Entities.Level", "Level") + .WithMany() + .HasForeignKey("LevelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GermanApp.Domain.Entities.Level", null) + .WithMany("Lessons") + .HasForeignKey("LevelId1"); + + b.Navigation("Level"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b => + { + b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson") + .WithMany() + .HasForeignKey("LessonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Lesson"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b => + { + b.HasOne("GermanApp.Domain.Entities.QuizQuestion", "QuizQuestion") + .WithMany("Options") + .HasForeignKey("QuizQuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("QuizQuestion"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b => + { + b.HasOne("GermanApp.Domain.Entities.Quiz", "Quiz") + .WithMany("Questions") + .HasForeignKey("QuizId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Quiz"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b => + { + b.HasOne("GermanApp.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.StoryProgress", b => + { + b.HasOne("GermanApp.Domain.Entities.Level", "Level") + .WithMany() + .HasForeignKey("LevelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GermanApp.Domain.Entities.StorySegment", "StorySegment") + .WithMany() + .HasForeignKey("StorySegmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GermanApp.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GermanApp.Domain.Entities.User", null) + .WithMany("StoryProgress") + .HasForeignKey("UserId1"); + + b.Navigation("Level"); + + b.Navigation("StorySegment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.StorySegment", b => + { + b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson") + .WithMany() + .HasForeignKey("LessonId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GermanApp.Domain.Entities.Lesson", null) + .WithMany("StorySegments") + .HasForeignKey("LessonId1"); + + b.HasOne("GermanApp.Domain.Entities.Level", "Level") + .WithMany() + .HasForeignKey("LevelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GermanApp.Domain.Entities.Level", null) + .WithMany("StorySegments") + .HasForeignKey("LevelId1"); + + b.Navigation("Lesson"); + + b.Navigation("Level"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b => + { + b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson") + .WithMany() + .HasForeignKey("LessonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GermanApp.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Lesson"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b => + { + b.Navigation("StorySegments"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.Level", b => + { + b.Navigation("Lessons"); + + b.Navigation("StorySegments"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b => + { + b.Navigation("Questions"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.User", b => + { + b.Navigation("StoryProgress"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GermanApp/Infrastructure/Data/Migrations/20260614101057_AddRoleToUser.cs b/GermanApp/Infrastructure/Data/Migrations/20260614101057_AddRoleToUser.cs new file mode 100644 index 0000000..756b15e --- /dev/null +++ b/GermanApp/Infrastructure/Data/Migrations/20260614101057_AddRoleToUser.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GermanApp.Infrastructure.Data.Migrations +{ + /// + public partial class AddRoleToUser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Role", + table: "Users", + type: "character varying(20)", + maxLength: 20, + nullable: false, + defaultValue: "User"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Role", + table: "Users"); + } + } +} diff --git a/GermanApp/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/GermanApp/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index ef11d61..d2f9284 100644 --- a/GermanApp/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/GermanApp/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -438,6 +438,13 @@ namespace GermanApp.Migrations .HasMaxLength(255) .HasColumnType("character varying(255)"); + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("User"); + b.Property("Streak") .ValueGeneratedOnAdd() .HasColumnType("integer") diff --git a/GermanApp/Infrastructure/Data/Repositories/UserRepository.cs b/GermanApp/Infrastructure/Data/Repositories/UserRepository.cs new file mode 100644 index 0000000..1b47b8f --- /dev/null +++ b/GermanApp/Infrastructure/Data/Repositories/UserRepository.cs @@ -0,0 +1,59 @@ +using GermanApp.Domain.Entities; +using GermanApp.Domain.Interfaces; +using GermanApp.Infrastructure.Data.DbContext; +using Microsoft.EntityFrameworkCore; + +namespace GermanApp.Infrastructure.Data.Repositories; + +/// +/// Entity Framework Core implementation of IRepository. +/// This is part of the Infrastructure layer. +/// +public class UserRepository : IRepository +{ + private readonly AppDbContext _context; + + public UserRepository(AppDbContext context) + { + _context = context; + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.Users + .Include(u => u.StoryProgress) + .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await _context.Users + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task AddAsync(User entity, CancellationToken cancellationToken = default) + { + await _context.Users.AddAsync(entity, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return entity; + } + + public async Task UpdateAsync(User entity, CancellationToken cancellationToken = default) + { + _context.Users.Update(entity); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteAsync(User entity, CancellationToken cancellationToken = default) + { + _context.Users.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task ExistsAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.Users + .AnyAsync(u => u.Id == id, cancellationToken); + } +} diff --git a/GermanApp/Infrastructure/Services/AuthService.cs b/GermanApp/Infrastructure/Services/AuthService.cs index e755ad7..80b24b6 100644 --- a/GermanApp/Infrastructure/Services/AuthService.cs +++ b/GermanApp/Infrastructure/Services/AuthService.cs @@ -152,7 +152,7 @@ public class AuthService : IAuthService new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Name, user.Username), new Claim(ClaimTypes.Email, user.Email), - new Claim(ClaimTypes.Role, "User") + new Claim(ClaimTypes.Role, user.Role) // Use user's actual role (User or Admin) }; var token = new JwtSecurityToken( @@ -227,4 +227,64 @@ public class AuthService : IAuthService storedToken.Revoke(); await _dbContext.SaveChangesAsync(); } + + /// + /// Creates the first admin user (bootstrap). + /// This is a special method that creates an admin user without requiring authentication. + /// Only works if no admin user exists yet. + /// + /// Admin user registration data + /// Authentication response with token + public async Task CreateAdminUserAsync(RegisterDto registerDto) + { + // Check if any admin user already exists + if (await _dbContext.Users.AnyAsync(u => u.Role == "Admin")) + throw new InvalidOperationException("Admin user already exists. Bootstrap endpoint can only be used once."); + + // Check if username or email already exists + if (await _dbContext.Users.AnyAsync(u => u.Username == registerDto.Username)) + throw new InvalidOperationException("Username already taken"); + + if (await _dbContext.Users.AnyAsync(u => u.Email == registerDto.Email)) + throw new InvalidOperationException("Email already in use"); + + // Hash password and create user + var user = User.Create(registerDto.Username, registerDto.Email.ToLowerInvariant(), string.Empty); + var passwordHash = _passwordHasher.HashPassword(user, registerDto.Password); + user.ChangePassword(passwordHash); + + // Assign admin role + user.AssignAdminRole(); + + _dbContext.Users.Add(user); + await _dbContext.SaveChangesAsync(); + + // Generate JWT token + var token = GenerateJwtToken(user); + + // Generate and store refresh token + var refreshTokenString = GenerateRefreshTokenString(); + var refreshToken = RefreshToken.Create(user.Id, refreshTokenString); + _dbContext.RefreshTokens.Add(refreshToken); + await _dbContext.SaveChangesAsync(); + + return new AuthResponse + { + UserId = user.Id, + Username = user.Username, + Email = user.Email, + Token = token, + RefreshToken = refreshTokenString, + ExpiresAt = DateTime.UtcNow.AddHours(24) + }; + } + + /// + /// Checks if an admin user already exists. + /// + /// True if admin user exists, false otherwise + public async Task AdminUserExistsAsync() + { + return await _dbContext.Users.AnyAsync(u => u.Role == "Admin"); + } } diff --git a/GermanApp/Presentation/Controllers/AdminController.cs b/GermanApp/Presentation/Controllers/AdminController.cs new file mode 100644 index 0000000..5d96d7f --- /dev/null +++ b/GermanApp/Presentation/Controllers/AdminController.cs @@ -0,0 +1,269 @@ +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.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace GermanApp.Presentation.Controllers; + +/// +/// API controller for admin operations. +/// All endpoints require Admin role. +/// This is part of the Presentation layer. +/// +[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 + // ============================================ + + /// + /// Gets all users (Admin only). + /// + /// Cancellation token + /// List of all users + [HttpGet("users")] + [ProducesResponseType(typeof(IReadOnlyList), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.Unauthorized)] + public async Task GetAllUsersAsync(CancellationToken cancellationToken = default) + { + var users = await _adminService.GetAllUsersAsync(cancellationToken); + return Ok(users); + } + + /// + /// Gets a specific user by ID (Admin only). + /// + /// The user ID + /// Cancellation token + /// The user DTO + [HttpGet("users/{userId}")] + [ProducesResponseType(typeof(AdminUserDto), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + [ProducesResponseType((int)HttpStatusCode.Unauthorized)] + public async Task GetUserByIdAsync(int userId, CancellationToken cancellationToken = default) + { + var user = await _adminService.GetUserByIdAsync(userId, cancellationToken); + + if (user == null) + return NotFound(); + + return Ok(user); + } + + /// + /// Updates a user's role (Admin only). + /// + /// The user ID + /// The role update data + /// Cancellation token + /// The updated user DTO + [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 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); + } + } + + /// + /// Deletes a user (Admin only, cannot delete self). + /// + /// The user ID to delete + /// Cancellation token + /// Success or error + [HttpDelete("users/{userId}")] + [ProducesResponseType((int)HttpStatusCode.NoContent)] + [ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + [ProducesResponseType((int)HttpStatusCode.Unauthorized)] + public async Task DeleteUserAsync(int userId, CancellationToken cancellationToken = default) + { + try + { + var adminUserId = int.Parse(User.FindFirst("nameid")?.Value ?? "0"); + var result = await _adminService.DeleteUserAsync(userId, adminUserId, cancellationToken); + + if (!result) + return NotFound(); + + return NoContent(); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + // ============================================ + // DASHBOARD ENDPOINTS + // ============================================ + + /// + /// Gets admin dashboard statistics. + /// + /// Cancellation token + /// Dashboard statistics + [HttpGet("dashboard/stats")] + [ProducesResponseType(typeof(AdminDashboardStatsDto), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.Unauthorized)] + public async Task GetDashboardStatsAsync(CancellationToken cancellationToken = default) + { + var stats = await _adminService.GetDashboardStatsAsync(cancellationToken); + return Ok(stats); + } + + // ============================================ + // REPORT ENDPOINTS + // ============================================ + + /// + /// Generates a progress report for a specific user. + /// + /// The user ID + /// Cancellation token + /// User progress report + [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 GetUserReportAsync(int userId, CancellationToken cancellationToken = default) + { + try + { + var report = await _reportService.GenerateUserReportAsync(userId, cancellationToken); + return Ok(report); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Generates progress reports for all users. + /// + /// Cancellation token + /// List of all user progress reports + [HttpGet("reports/all-users")] + [ProducesResponseType(typeof(IReadOnlyList), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.Unauthorized)] + public async Task GetAllUserReportsAsync(CancellationToken cancellationToken = default) + { + var reports = await _reportService.GenerateAllUserReportsAsync(cancellationToken); + return Ok(reports); + } + + /// + /// Gets lesson completion data for a user. + /// + /// The user ID + /// Cancellation token + /// List of lesson completion data + [HttpGet("reports/users/{userId}/lessons")] + [ProducesResponseType(typeof(IReadOnlyList), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + [ProducesResponseType((int)HttpStatusCode.Unauthorized)] + public async Task GetUserLessonProgressAsync(int userId, CancellationToken cancellationToken = default) + { + var progress = await _reportService.GetUserLessonProgressAsync(userId, cancellationToken); + return Ok(progress); + } + + /// + /// Gets quiz results for a user. + /// + /// The user ID + /// Cancellation token + /// List of quiz results + [HttpGet("reports/users/{userId}/quizzes")] + [ProducesResponseType(typeof(IReadOnlyList), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + [ProducesResponseType((int)HttpStatusCode.Unauthorized)] + public async Task GetUserQuizResultsAsync(int userId, CancellationToken cancellationToken = default) + { + var results = await _reportService.GetUserQuizResultsAsync(userId, cancellationToken); + return Ok(results); + } + + /// + /// Exports all user reports as CSV. + /// + /// Cancellation token + /// CSV content + [HttpGet("reports/export/csv")] + [ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.Unauthorized)] + public async Task ExportReportsAsCsvAsync(CancellationToken cancellationToken = default) + { + var csv = await _reportService.ExportReportsAsCsvAsync(cancellationToken); + return Ok(new { CsvContent = csv }); + } + + /// + /// Gets a specific user's full report (combined data). + /// + /// The user ID + /// Cancellation token + /// Combined user report data + [HttpGet("reports/users/{userId}/full")] + [ProducesResponseType((int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + [ProducesResponseType((int)HttpStatusCode.Unauthorized)] + public async Task 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); + } + } +} diff --git a/GermanApp/Presentation/Controllers/BootstrapController.cs b/GermanApp/Presentation/Controllers/BootstrapController.cs new file mode 100644 index 0000000..283bdc3 --- /dev/null +++ b/GermanApp/Presentation/Controllers/BootstrapController.cs @@ -0,0 +1,76 @@ +using GermanApp.Application.DTOs.Auth; +using GermanApp.Application.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Net; + +namespace GermanApp.Presentation.Controllers; + +/// +/// Controller for bootstrap operations (first admin user creation). +/// This controller should be removed or disabled after the first admin is created. +/// This is part of the Presentation layer. +/// +[ApiController] +[Route("api/[controller]")] +public class BootstrapController : ControllerBase +{ + private readonly IAuthService _authService; + + public BootstrapController(IAuthService authService) + { + _authService = authService; + } + + /// + /// Creates the first admin user. + /// This endpoint is PUBLIC (no authentication required) but can only be used once. + /// After creating the first admin, this endpoint will return 400 BadRequest. + /// + /// Admin user registration data + /// Authentication response with JWT token + [HttpPost("admin")] + [AllowAnonymous] + [ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType(typeof(string), (int)HttpStatusCode.Conflict)] + public async Task CreateAdminUser([FromBody] RegisterDto registerDto) + { + try + { + var result = await _authService.CreateAdminUserAsync(registerDto); + return Ok(result); + } + catch (InvalidOperationException ex) + { + // Admin already exists - this is expected after first use + if (ex.Message.Contains("Admin user already exists")) + return Conflict(ex.Message); + return BadRequest(ex.Message); + } + catch (Exception ex) + { + return StatusCode((int)HttpStatusCode.InternalServerError, ex.Message); + } + } + + /// + /// Checks if an admin user already exists. + /// + /// True if admin exists, false otherwise + [HttpGet("admin-exists")] + [AllowAnonymous] + [ProducesResponseType(typeof(bool), (int)HttpStatusCode.OK)] + public async Task CheckAdminExists() + { + try + { + var exists = await _authService.AdminUserExistsAsync(); + return Ok(exists); + } + catch (Exception ex) + { + return StatusCode((int)HttpStatusCode.InternalServerError, ex.Message); + } + } +} diff --git a/GermanApp/Presentation/Controllers/LessonsController.cs b/GermanApp/Presentation/Controllers/LessonsController.cs index f271209..a008aa2 100644 --- a/GermanApp/Presentation/Controllers/LessonsController.cs +++ b/GermanApp/Presentation/Controllers/LessonsController.cs @@ -28,7 +28,6 @@ public class LessonsController : ControllerBase /// /// List of all lessons [HttpGet] - [AllowAnonymous] public async Task GetAllLessonsAsync(CancellationToken cancellationToken = default) { var lessons = await _lessonService.GetAllLessonsAsync(cancellationToken); @@ -40,7 +39,6 @@ public class LessonsController : ControllerBase /// /// List of all lessons in order [HttpGet("ordered")] - [AllowAnonymous] public async Task GetAllOrderedLessonsAsync(CancellationToken cancellationToken = default) { var lessons = await _lessonService.GetAllOrderedLessonsAsync(cancellationToken); @@ -53,7 +51,6 @@ public class LessonsController : ControllerBase /// The lesson ID /// The lesson with the specified ID [HttpGet("{id}")] - [AllowAnonymous] public async Task GetLessonByIdAsync(int id, CancellationToken cancellationToken = default) { var lesson = await _lessonService.GetLessonByIdAsync(id, cancellationToken); @@ -68,7 +65,6 @@ public class LessonsController : ControllerBase /// The level ID /// List of lessons for the specified level [HttpGet("by-level/{levelId}")] - [AllowAnonymous] public async Task GetLessonsByLevelAsync(int levelId, CancellationToken cancellationToken = default) { var lessons = await _lessonService.GetLessonsByLevelAsync(levelId, cancellationToken); @@ -80,7 +76,6 @@ public class LessonsController : ControllerBase /// /// List of beginner lessons [HttpGet("beginner")] - [AllowAnonymous] public async Task GetBeginnerLessonsAsync(CancellationToken cancellationToken = default) { var lessons = await _lessonService.GetBeginnerLessonsAsync(cancellationToken); @@ -92,7 +87,6 @@ public class LessonsController : ControllerBase /// /// List of advanced lessons [HttpGet("advanced")] - [AllowAnonymous] public async Task GetAdvancedLessonsAsync(CancellationToken cancellationToken = default) { var lessons = await _lessonService.GetAdvancedLessonsAsync(cancellationToken); @@ -149,7 +143,6 @@ public class LessonsController : ControllerBase /// The level ID /// The first lesson in the level [HttpGet("first/{levelId}")] - [AllowAnonymous] public async Task GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default) { var lesson = await _lessonService.GetFirstLessonInLevelAsync(levelId, cancellationToken); @@ -164,7 +157,6 @@ public class LessonsController : ControllerBase /// The current lesson ID /// The next lesson [HttpGet("next/{currentLessonId}")] - [AllowAnonymous] public async Task GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default) { var lesson = await _lessonService.GetNextLessonAsync(currentLessonId, cancellationToken); diff --git a/GermanApp/Presentation/Controllers/LevelsController.cs b/GermanApp/Presentation/Controllers/LevelsController.cs index e59afcd..a0e2af0 100644 --- a/GermanApp/Presentation/Controllers/LevelsController.cs +++ b/GermanApp/Presentation/Controllers/LevelsController.cs @@ -26,7 +26,6 @@ public class LevelsController : ControllerBase /// /// List of all levels [HttpGet] - [AllowAnonymous] public async Task GetAllLevelsAsync(CancellationToken cancellationToken = default) { var levels = await _levelService.GetAllLevelsAsync(cancellationToken); @@ -39,7 +38,6 @@ public class LevelsController : ControllerBase /// The level ID /// The level with the specified ID [HttpGet("{id}")] - [AllowAnonymous] public async Task GetLevelByIdAsync(int id, CancellationToken cancellationToken = default) { var level = await _levelService.GetLevelByIdAsync(id, cancellationToken); @@ -54,7 +52,6 @@ public class LevelsController : ControllerBase /// The level code /// The level with the specified code [HttpGet("by-code/{code}")] - [AllowAnonymous] public async Task GetLevelByCodeAsync(string code, CancellationToken cancellationToken = default) { var level = await _levelService.GetLevelByCodeAsync(code, cancellationToken); @@ -112,7 +109,6 @@ public class LevelsController : ControllerBase /// /// The first level [HttpGet("first")] - [AllowAnonymous] public async Task GetFirstLevelAsync(CancellationToken cancellationToken = default) { var level = await _levelService.GetFirstLevelAsync(cancellationToken); @@ -127,7 +123,6 @@ public class LevelsController : ControllerBase /// The current level ID /// The next level [HttpGet("next/{currentLevelId}")] - [AllowAnonymous] public async Task GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default) { var level = await _levelService.GetNextLevelAsync(currentLevelId, cancellationToken); diff --git a/GermanApp/Presentation/Controllers/QuizQuestionsController.cs b/GermanApp/Presentation/Controllers/QuizQuestionsController.cs index 4eeb360..234d8ca 100644 --- a/GermanApp/Presentation/Controllers/QuizQuestionsController.cs +++ b/GermanApp/Presentation/Controllers/QuizQuestionsController.cs @@ -27,7 +27,6 @@ public class QuizQuestionsController : ControllerBase /// /// List of all quiz questions [HttpGet] - [AllowAnonymous] public async Task GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default) { var questions = await _quizQuestionService.GetAllQuizQuestionsAsync(cancellationToken); @@ -40,7 +39,6 @@ public class QuizQuestionsController : ControllerBase /// The quiz ID /// List of quiz questions for the specified quiz [HttpGet("by-quiz/{quizId}")] - [AllowAnonymous] public async Task GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default) { var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken); @@ -53,7 +51,6 @@ public class QuizQuestionsController : ControllerBase /// The lesson ID /// List of quiz questions for the specified lesson [HttpGet("by-lesson/{lessonId}")] - [AllowAnonymous] public async Task GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default) { var questions = await _quizQuestionService.GetActiveQuizQuestionsByLessonAsync(lessonId, cancellationToken); @@ -66,7 +63,6 @@ public class QuizQuestionsController : ControllerBase /// The quiz question ID /// The quiz question with the specified ID [HttpGet("{id}")] - [AllowAnonymous] public async Task GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default) { var question = await _quizQuestionService.GetQuizQuestionByIdAsync(id, cancellationToken); @@ -82,7 +78,6 @@ public class QuizQuestionsController : ControllerBase /// Number of questions to return /// List of random quiz questions for the quiz [HttpGet("random/{quizId}/{count}")] - [AllowAnonymous] public async Task GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default) { var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken); @@ -96,7 +91,6 @@ public class QuizQuestionsController : ControllerBase /// Number of questions to return /// List of random quiz questions for the lesson [HttpGet("random/by-lesson/{lessonId}/{count}")] - [AllowAnonymous] public async Task GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default) { var questions = await _quizQuestionService.GetRandomQuizQuestionsForLessonAsync(lessonId, count, cancellationToken); @@ -109,7 +103,6 @@ public class QuizQuestionsController : ControllerBase /// The difficulty level (1-5) /// List of quiz questions with the specified difficulty [HttpGet("by-difficulty/{difficulty}")] - [AllowAnonymous] public async Task GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default) { var questions = await _quizQuestionService.GetQuizQuestionsByDifficultyAsync(difficulty, cancellationToken); @@ -122,7 +115,6 @@ public class QuizQuestionsController : ControllerBase /// The question type /// List of quiz questions with the specified type [HttpGet("by-type/{type}")] - [AllowAnonymous] public async Task GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default) { var questions = await _quizQuestionService.GetQuizQuestionsByTypeAsync(type, cancellationToken); @@ -179,7 +171,6 @@ public class QuizQuestionsController : ControllerBase /// The question ID /// List of quiz options for the question [HttpGet("options/{questionId}")] - [AllowAnonymous] public async Task GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default) { var options = await _quizQuestionService.GetQuizOptionsByQuestionAsync(questionId, cancellationToken); @@ -192,7 +183,6 @@ public class QuizQuestionsController : ControllerBase /// The question ID /// The correct option for the question [HttpGet("correct-option/{questionId}")] - [AllowAnonymous] public async Task GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default) { var option = await _quizQuestionService.GetCorrectOptionAsync(questionId, cancellationToken); @@ -207,7 +197,6 @@ public class QuizQuestionsController : ControllerBase /// The question ID /// List of correct options for the question [HttpGet("correct-options/{questionId}")] - [AllowAnonymous] public async Task GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default) { var options = await _quizQuestionService.GetCorrectOptionsAsync(questionId, cancellationToken); @@ -281,7 +270,6 @@ public class QuizQuestionsController : ControllerBase /// The lesson ID /// The count of quiz questions for the lesson [HttpGet("count/{lessonId}")] - [AllowAnonymous] public async Task GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default) { var count = await _quizQuestionService.GetQuizQuestionCountForLessonAsync(lessonId, cancellationToken); @@ -294,7 +282,6 @@ public class QuizQuestionsController : ControllerBase /// The lesson ID /// The difficulty distribution for the lesson [HttpGet("difficulty-distribution/{lessonId}")] - [AllowAnonymous] public async Task GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default) { var distribution = await _quizQuestionService.GetDifficultyDistributionAsync(lessonId, cancellationToken); @@ -307,7 +294,6 @@ public class QuizQuestionsController : ControllerBase /// The lesson ID /// The type distribution for the lesson [HttpGet("type-distribution/{lessonId}")] - [AllowAnonymous] public async Task GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default) { var distribution = await _quizQuestionService.GetTypeDistributionAsync(lessonId, cancellationToken); diff --git a/GermanApp/Presentation/Controllers/QuizzesController.cs b/GermanApp/Presentation/Controllers/QuizzesController.cs index 097c464..8ecb00c 100644 --- a/GermanApp/Presentation/Controllers/QuizzesController.cs +++ b/GermanApp/Presentation/Controllers/QuizzesController.cs @@ -31,7 +31,6 @@ public class QuizzesController : ControllerBase /// /// List of all quizzes [HttpGet] - [AllowAnonymous] public async Task GetAllQuizzesAsync(CancellationToken cancellationToken = default) { var quizzes = await _quizService.GetAllQuizzesAsync(cancellationToken); @@ -43,7 +42,6 @@ public class QuizzesController : ControllerBase /// /// List of quiz summary items [HttpGet("list")] - [AllowAnonymous] public async Task GetAllQuizListItemsAsync(CancellationToken cancellationToken = default) { var quizzes = await _quizService.GetAllQuizListItemsAsync(cancellationToken); @@ -56,7 +54,6 @@ public class QuizzesController : ControllerBase /// The quiz ID /// The quiz with the specified ID [HttpGet("{id}")] - [AllowAnonymous] public async Task GetQuizByIdAsync(int id, CancellationToken cancellationToken = default) { var quiz = await _quizService.GetQuizByIdAsync(id, cancellationToken); @@ -71,7 +68,6 @@ public class QuizzesController : ControllerBase /// The quiz ID /// The quiz with questions [HttpGet("{id}/with-questions")] - [AllowAnonymous] public async Task GetQuizWithQuestionsAsync(int id, CancellationToken cancellationToken = default) { var quiz = await _quizService.GetQuizWithQuestionsAsync(id, cancellationToken); @@ -86,7 +82,6 @@ public class QuizzesController : ControllerBase /// The lesson ID /// List of quizzes for the specified lesson [HttpGet("by-lesson/{lessonId}")] - [AllowAnonymous] public async Task GetQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default) { var quizzes = await _quizService.GetQuizzesByLessonAsync(lessonId, cancellationToken); @@ -99,7 +94,6 @@ public class QuizzesController : ControllerBase /// The lesson ID /// List of active quizzes for the specified lesson [HttpGet("active/by-lesson/{lessonId}")] - [AllowAnonymous] public async Task GetActiveQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default) { var quizzes = await _quizService.GetActiveQuizzesByLessonAsync(lessonId, cancellationToken); @@ -112,7 +106,6 @@ public class QuizzesController : ControllerBase /// The lesson ID /// The first quiz for the lesson [HttpGet("first/by-lesson/{lessonId}")] - [AllowAnonymous] public async Task GetFirstQuizByLessonAsync(int lessonId, CancellationToken cancellationToken = default) { var quiz = await _quizService.GetFirstQuizByLessonAsync(lessonId, cancellationToken); @@ -126,7 +119,6 @@ public class QuizzesController : ControllerBase /// /// List of active quizzes [HttpGet("active")] - [AllowAnonymous] public async Task GetActiveQuizzesAsync(CancellationToken cancellationToken = default) { var quizzes = await _quizService.GetActiveQuizzesAsync(cancellationToken); @@ -213,7 +205,6 @@ public class QuizzesController : ControllerBase /// The quiz ID /// True if the quiz exists [HttpGet("exists/{id}")] - [AllowAnonymous] public async Task ExistsAsync(int id, CancellationToken cancellationToken = default) { var exists = await _quizService.ExistsAsync(id, cancellationToken); @@ -226,7 +217,6 @@ public class QuizzesController : ControllerBase /// The lesson ID /// True if a quiz exists for the lesson [HttpGet("exists/by-lesson/{lessonId}")] - [AllowAnonymous] public async Task ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default) { var exists = await _quizService.ExistsByLessonAsync(lessonId, cancellationToken); @@ -238,7 +228,6 @@ public class QuizzesController : ControllerBase /// /// The total count of quizzes [HttpGet("count")] - [AllowAnonymous] public async Task GetTotalQuizCountAsync(CancellationToken cancellationToken = default) { var count = await _quizService.GetTotalQuizCountAsync(cancellationToken); @@ -251,7 +240,6 @@ public class QuizzesController : ControllerBase /// The lesson ID /// The count of quizzes for the lesson [HttpGet("count/by-lesson/{lessonId}")] - [AllowAnonymous] public async Task GetQuizCountByLessonAsync(int lessonId, CancellationToken cancellationToken = default) { var count = await _quizService.GetQuizCountByLessonAsync(lessonId, cancellationToken); @@ -265,7 +253,6 @@ public class QuizzesController : ControllerBase /// Maximum passing score /// List of quizzes in the score range [HttpGet("by-passing-score/{minScore}/{maxScore}")] - [AllowAnonymous] public async Task GetQuizzesByPassingScoreRangeAsync(int minScore, int maxScore, CancellationToken cancellationToken = default) { var quizzes = await _quizService.GetQuizzesByPassingScoreRangeAsync(minScore, maxScore, cancellationToken); @@ -278,7 +265,6 @@ public class QuizzesController : ControllerBase /// The quiz ID /// List of quiz questions for the quiz [HttpGet("{quizId}/questions")] - [AllowAnonymous] public async Task GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default) { var questions = await _quizQuestionService.GetQuizQuestionsByQuizAsync(quizId, cancellationToken); @@ -291,7 +277,6 @@ public class QuizzesController : ControllerBase /// The quiz ID /// List of active quiz questions for the quiz [HttpGet("{quizId}/questions/active")] - [AllowAnonymous] public async Task GetActiveQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default) { var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken); @@ -305,7 +290,6 @@ public class QuizzesController : ControllerBase /// Number of questions to return /// List of random quiz questions [HttpGet("{quizId}/questions/random/{count}")] - [AllowAnonymous] public async Task GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default) { var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken); @@ -335,7 +319,6 @@ public class QuizzesController : ControllerBase /// The quiz ID /// The total points for the quiz [HttpGet("{quizId}/total-points")] - [AllowAnonymous] public async Task GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default) { var points = await _quizQuestionService.GetTotalPointsForQuizAsync(quizId, cancellationToken); diff --git a/GermanApp/Program.cs b/GermanApp/Program.cs index d8ef7ac..0148876 100644 --- a/GermanApp/Program.cs +++ b/GermanApp/Program.cs @@ -154,6 +154,7 @@ try }); // Register repositories (Infrastructure implementations of Domain interfaces) + builder.Services.AddScoped, UserRepository>(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -235,6 +236,11 @@ try // Register Story services builder.Services.AddScoped(); builder.Services.AddScoped(); + + // Register Admin services + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped, CreateLessonCommandHandler>(); // Note: QuizQuestionService requires IQuizRepository, ILessonRepository, and ProgressService which are registered above diff --git a/german-app-frontend/.env.development b/german-app-frontend/.env.development new file mode 100644 index 0000000..35d03e6 --- /dev/null +++ b/german-app-frontend/.env.development @@ -0,0 +1,2 @@ +# Development environment variables +VITE_API_URL=http://localhost:5000 diff --git a/german-app-frontend/src/App.tsx b/german-app-frontend/src/App.tsx index 0a58f8c..658c312 100644 --- a/german-app-frontend/src/App.tsx +++ b/german-app-frontend/src/App.tsx @@ -1,20 +1,15 @@ import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'; +import { AuthProvider } from '@/stores/authStore'; +import { ProtectedRoute, AdminRoute } from '@/components/features/auth/ProtectedRoute'; +import { HomePage } from '@/pages/HomePage'; +import { LandingPage } from '@/pages/LandingPage'; +import { LoginPage } from '@/pages/LoginPage'; +import { RegisterPage } from '@/pages/RegisterPage'; import StoryPage from './pages/StoryPage'; import './index.css'; -function HomePage() { - return ( -
-

DeutschLernen

-

German Learning Application

- -
- ); -} +// Re-export for easier imports +export { AuthProvider } from '@/stores/authStore'; function NotFoundPage() { return ( @@ -26,32 +21,80 @@ function NotFoundPage() { ); } -export default function App() { +// Layout component with header and footer +function AppLayout({ children }: { children: React.ReactNode }) { return ( - -
-
- -

DeutschLernen

- - -
+
+
+ +

DeutschLernen

+ + +
-
- - } /> - } /> - } /> - -
+
{children}
-
-

© {new Date().getFullYear()} DeutschLernen

-
-
- +
+

© {new Date().getFullYear()} DeutschLernen

+
+
+ ); +} + +export default function App() { + return ( + + + + {/* Public routes (no authentication required) */} + } /> + } /> + } /> + + {/* Protected routes (require authentication) */} + + + + + + } + /> + + + + + + } + /> + + {/* Admin routes (require authentication + admin role) */} + + +
+

Admin Dashboard

+

Admin content goes here

+
+
+ + } + /> + + {/* Catch-all route */} + } /> +
+
+
); } diff --git a/german-app-frontend/src/components/features/auth/ProtectedRoute.tsx b/german-app-frontend/src/components/features/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..1f1909f --- /dev/null +++ b/german-app-frontend/src/components/features/auth/ProtectedRoute.tsx @@ -0,0 +1,66 @@ +/** + * ProtectedRoute component for React Router + * Redirects unauthenticated users to login page + */ + +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '@/stores/authStore'; +import type { ReactNode } from 'react'; + +interface ProtectedRouteProps { + children: ReactNode; + redirectTo?: string; +} + +export function ProtectedRoute({ + children, + redirectTo = '/login' +}: ProtectedRouteProps) { + const { isAuthenticated, isLoading } = useAuth(); + const location = useLocation(); + + // Show loading state while checking authentication + if (isLoading) { + return ( +
+
+

Loading...

+
+ ); + } + + // Redirect to login if not authenticated + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} + +// Protected route for admin-only content +export function AdminRoute({ children }: { children: ReactNode }) { + const { isAuthenticated, user, isLoading } = useAuth(); + const location = useLocation(); + + // Show loading state while checking authentication + if (isLoading) { + return ( +
+
+

Loading...

+
+ ); + } + + // Redirect to login if not authenticated + if (!isAuthenticated) { + return ; + } + + // Redirect to home if authenticated but not admin + if (user?.role !== 'Admin') { + return ; + } + + return <>{children}; +} diff --git a/german-app-frontend/src/index.css b/german-app-frontend/src/index.css index 03451da..b95174c 100644 --- a/german-app-frontend/src/index.css +++ b/german-app-frontend/src/index.css @@ -760,3 +760,456 @@ body { font-size: 1.25rem; } } + +/* ============================================ + AUTH PAGES STYLES + ============================================ */ + +/* Auth Page Container */ +.auth-page { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: 2rem; + background-color: var(--background-color); +} + +.auth-card { + background: var(--card-background); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + padding: 2.5rem; + width: 100%; + max-width: 400px; +} + +.auth-title { + font-size: 2rem; + font-weight: 700; + color: var(--text-color); + margin-bottom: 0.5rem; + text-align: center; +} + +.auth-subtitle { + color: var(--text-muted); + text-align: center; + margin-bottom: 2rem; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-group label { + font-weight: 600; + color: var(--text-color); +} + +.form-input { + padding: 0.75rem 1rem; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + font-size: 1rem; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.form-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); +} + +.form-input:disabled { + background-color: #f1f5f9; + cursor: not-allowed; +} + +.auth-links { + margin-top: 1.5rem; + text-align: center; +} + +.auth-links p { + color: var(--text-muted); +} + +.link-btn { + background: none; + border: none; + color: var(--primary-color); + font-weight: 600; + cursor: pointer; + padding: 0; + margin-left: 0.25rem; + text-decoration: underline; +} + +.link-btn:hover { + color: var(--primary-hover); +} + +/* ============================================ + LANDING PAGE STYLES + ============================================ */ + +.landing-page { + display: flex; + flex-direction: column; + align-items: center; + min-height: 100vh; + padding: 2rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.landing-hero { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + max-width: 800px; + margin-bottom: 3rem; +} + +.landing-title { + font-size: 3rem; + font-weight: 800; + margin-bottom: 1rem; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); +} + +.landing-subtitle { + font-size: 1.5rem; + margin-bottom: 1rem; + opacity: 0.9; +} + +.landing-description { + font-size: 1.1rem; + margin-bottom: 2rem; + opacity: 0.8; + max-width: 600px; +} + +.landing-actions { + display: flex; + gap: 1rem; + justify-content: center; +} + +.btn-large { + padding: 1rem 2rem; + font-size: 1.1rem; + font-weight: 600; +} + +.landing-features { + display: flex; + flex-direction: column; + align-items: center; + max-width: 800px; + width: 100%; + margin-top: 3rem; +} + +.landing-features h2 { + font-size: 2rem; + margin-bottom: 2rem; + color: white; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + width: 100%; +} + +.feature-card { + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(10px); + border-radius: var(--radius-lg); + padding: 1.5rem; + text-align: center; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.feature-card h3 { + font-size: 1.25rem; + margin-bottom: 0.5rem; + color: white; +} + +.feature-card p { + opacity: 0.9; + font-size: 0.95rem; +} + +.landing-footer { + margin-top: 3rem; + opacity: 0.7; + font-size: 0.9rem; +} + +/* ============================================ + HOME PAGE STYLES + ============================================ */ + +.home-page { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.home-header { + background: linear-gradient(135deg, var(--primary-color) 0%, #5b21b6 100%); + color: white; + padding: 3rem 2rem; + text-align: center; +} + +.home-header h1 { + font-size: 2.5rem; + margin-bottom: 1rem; + color: white; +} + +.welcome-message { + font-size: 1.25rem; + margin-bottom: 0.5rem; + opacity: 0.95; +} + +.user-stats { + font-size: 1rem; + opacity: 0.9; +} + +.home-content { + flex: 1; + padding: 2rem; + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +.content-section { + margin-bottom: 3rem; +} + +.content-section h2 { + font-size: 1.5rem; + margin-bottom: 1.5rem; + color: var(--text-color); + border-bottom: 2px solid var(--border-color); + padding-bottom: 0.5rem; +} + +.quick-access { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; +} + +.quick-access-card { + background: var(--card-background); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 2rem; + text-align: center; + cursor: pointer; + transition: all 0.2s; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.quick-access-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); + border-color: var(--primary-color); +} + +.card-icon { + font-size: 2.5rem; +} + +.card-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-color); +} + +.card-description { + color: var(--text-muted); + font-size: 0.9rem; +} + +.progress-overview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; +} + +.progress-card { + background: var(--card-background); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 1.5rem; + text-align: center; +} + +.progress-card h3 { + font-size: 1.5rem; + color: var(--primary-color); + margin-bottom: 0.5rem; +} + +.progress-card p { + color: var(--text-muted); + font-size: 0.9rem; +} + +.admin-section { + background: #fef3c7; + border-left: 4px solid #f59e0b; + padding: 1.5rem; + border-radius: var(--radius-md); +} + +.admin-actions { + margin-top: 1rem; +} + +.btn-admin { + background: #7c2d12; + color: white; +} + +.btn-admin:hover { + background: #591c0b; +} + +.user-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; +} + +/* Loading Overlay */ +.loading-overlay { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + gap: 1rem; +} + +.spinner { + width: 50px; + height: 50px; + border: 4px solid var(--border-color); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ============================================ + BUTTON STYLES + ============================================ */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--radius-md); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary { + background: var(--primary-color); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--primary-hover); +} + +.btn-secondary { + background: var(--secondary-color); + color: white; +} + +.btn-secondary:hover:not(:disabled) { + background: #059669; +} + +.btn-full { + width: 100%; +} + +/* ============================================ + RESPONSIVE DESIGN FOR NEW PAGES + ============================================ */ + +@media (max-width: 768px) { + .landing-title { + font-size: 2rem; + } + + .landing-subtitle { + font-size: 1.25rem; + } + + .landing-actions { + flex-direction: column; + align-items: center; + } + + .features-grid { + grid-template-columns: 1fr; + } + + .home-header { + padding: 2rem 1rem; + } + + .home-header h1 { + font-size: 1.75rem; + } + + .quick-access { + grid-template-columns: 1fr; + } + + .progress-overview { + grid-template-columns: 1fr; + } +} diff --git a/german-app-frontend/src/lib/api/auth.ts b/german-app-frontend/src/lib/api/auth.ts new file mode 100644 index 0000000..1b4c518 --- /dev/null +++ b/german-app-frontend/src/lib/api/auth.ts @@ -0,0 +1,152 @@ +/** + * Auth API client for the DeutschLernen frontend + * Handles authentication requests to the backend + */ + +import type { + RegisterRequest, + LoginRequest, + AuthResponse, + CurrentUser, + RefreshTokenRequest, + RefreshTokenResponse, +} from '@/types/api/auth'; + +// Base API URL - uses /api prefix for Docker proxy +const BASE_URL = import.meta.env.PROD + ? '/api' + : import.meta.env.VITE_API_URL || 'http://localhost:5000/api'; + +/** + * Register a new user + */ +export async function register(request: RegisterRequest): Promise { + const response = await fetch(`${BASE_URL}/auth/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || 'Registration failed'); + } + + return response.json(); +} + +/** + * Login an existing user + */ +export async function login(request: LoginRequest): Promise { + const response = await fetch(`${BASE_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || 'Login failed'); + } + + return response.json(); +} + +/** + * Get current authenticated user info + */ +export async function getCurrentUser(token: string): Promise { + const response = await fetch(`${BASE_URL}/auth/me`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || 'Failed to get current user'); + } + + return response.json(); +} + +/** + * Refresh access token using refresh token + */ +export async function refreshToken(request: RefreshTokenRequest): Promise { + const response = await fetch(`${BASE_URL}/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request.refreshToken), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || 'Token refresh failed'); + } + + return response.json(); +} + +/** + * Revoke refresh token + */ +export async function revokeRefreshToken(refreshToken: string, accessToken: string): Promise { + const response = await fetch(`${BASE_URL}/auth/revoke-refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }, + body: JSON.stringify(refreshToken), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || 'Failed to revoke refresh token'); + } +} + +/** + * Check if an admin user exists (for bootstrap) + */ +export async function checkAdminExists(): Promise { + const response = await fetch(`${BASE_URL}/bootstrap/admin-exists`, { + method: 'GET', + }); + + if (!response.ok) { + return false; + } + + return response.json(); +} + +/** + * Create first admin user (bootstrap endpoint) + */ +export async function createAdminUser(request: RegisterRequest): Promise { + const response = await fetch(`${BASE_URL}/bootstrap/admin`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || 'Failed to create admin user'); + } + + return response.json(); +} diff --git a/german-app-frontend/src/pages/HomePage.tsx b/german-app-frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..f657804 --- /dev/null +++ b/german-app-frontend/src/pages/HomePage.tsx @@ -0,0 +1,105 @@ +/** + * Home page for authenticated users + * Shows overview of available content + */ + +import { useNavigate } from 'react-router-dom'; +import { useAuth, useIsAdmin } from '@/stores/authStore'; + +export function HomePage() { + const { user, logout } = useAuth(); + const isAdmin = useIsAdmin(); + const navigate = useNavigate(); + + return ( +
+
+

Welcome to DeutschLernen

+

+ Hello, {user?.username}! You're currently at level {user?.currentLevel}. +

+

+ Points: {user?.totalPoints} | Streak: {user?.streak} days +

+
+ +
+
+

Continue Learning

+
+ + + + + +
+
+ +
+

Your Progress

+
+
+

Level {user?.currentLevel}

+

Current CEFR level

+
+
+

{user?.totalPoints} pts

+

Total points earned

+
+
+

{user?.streak} days

+

Current learning streak

+
+
+
+ + {isAdmin && ( +
+

Admin Dashboard

+

You have administrator access to manage users and content.

+
+ +
+
+ )} + +
+
+ +
+
+
+
+ ); +} diff --git a/german-app-frontend/src/pages/LandingPage.tsx b/german-app-frontend/src/pages/LandingPage.tsx new file mode 100644 index 0000000..c0bdd0a --- /dev/null +++ b/german-app-frontend/src/pages/LandingPage.tsx @@ -0,0 +1,85 @@ +/** + * Public landing page for DeutschLernen + * Visitors see this page before authentication + */ + +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '@/stores/authStore'; +import { useEffect } from 'react'; + +export function LandingPage() { + const { isAuthenticated, isLoading } = useAuth(); + const navigate = useNavigate(); + + // Redirect authenticated users to home + useEffect(() => { + if (!isLoading && isAuthenticated) { + navigate('/'); + } + }, [isAuthenticated, isLoading, navigate]); + + if (isLoading) { + return ( +
+
+

Loading...

+
+ ); + } + + return ( +
+
+

DeutschLernen

+

+ Learn German with interactive stories, lessons, and quizzes +

+

+ Join our community and track your progress through CEFR levels A1 to C1. + All learning content requires registration and sign-in. +

+ +
+ + +
+
+ +
+

What You Get

+
+
+

📚 Interactive Lessons

+

Learn German vocabulary and grammar through structured lessons

+
+
+

📖 Engaging Stories

+

Read German stories at your level with audio support

+
+
+

🎯 Progress Tracking

+

Track your completion of lessons, quizzes, and stories

+
+
+

✅ Personalized Learning

+

Your progress is saved individually across all devices

+
+
+
+ +
+

All content requires authentication. No public access available.

+
+
+ ); +} diff --git a/german-app-frontend/src/pages/LoginPage.tsx b/german-app-frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..23a2e4a --- /dev/null +++ b/german-app-frontend/src/pages/LoginPage.tsx @@ -0,0 +1,100 @@ +/** + * Login page for user authentication + */ + +import { useState } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { useAuth } from '@/stores/authStore'; +import type { FormEvent } from 'react'; + +export function LoginPage() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { login, error, clearError } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + // Get redirect path from location state or default to home + const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/'; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + clearError(); + + try { + await login(email, password); + navigate(from, { replace: true }); + } catch (err) { + // Error is already handled by the auth store + console.error('Login error:', err); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+

Login

+

Sign in to access your German learning content

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + required + placeholder="Enter your email" + className="form-input" + /> +
+ +
+ + setPassword(e.target.value)} + required + placeholder="Enter your password" + className="form-input" + /> +
+ + +
+ +
+

+ Don't have an account?{' '} + +

+
+
+
+ ); +} diff --git a/german-app-frontend/src/pages/RegisterPage.tsx b/german-app-frontend/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..a583e51 --- /dev/null +++ b/german-app-frontend/src/pages/RegisterPage.tsx @@ -0,0 +1,150 @@ +/** + * Register page for new user registration + */ + +import { useState } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { useAuth } from '@/stores/authStore'; +import type { FormEvent } from 'react'; + +export function RegisterPage() { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [validationError, setValidationError] = useState(null); + + const { register, error, clearError } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + // Get redirect path from location state or default to home + const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/'; + + const validateForm = (): boolean => { + if (password !== confirmPassword) { + setValidationError('Passwords do not match'); + return false; + } + if (password.length < 8) { + setValidationError('Password must be at least 8 characters'); + return false; + } + clearError(); + setValidationError(null); + return true; + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + clearError(); + + try { + await register(username, email, password); + navigate(from, { replace: true }); + } catch (err) { + // Error is already handled by the auth store + console.error('Registration error:', err); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+

Create Account

+

Join DeutschLernen and start your German learning journey

+ + {(error || validationError) && ( +
+ {error || validationError} +
+ )} + +
+
+ + setUsername(e.target.value)} + required + minLength={3} + placeholder="Enter your username" + className="form-input" + /> +
+ +
+ + setEmail(e.target.value)} + required + placeholder="Enter your email" + className="form-input" + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={8} + placeholder="Enter your password (min 8 characters)" + className="form-input" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + placeholder="Confirm your password" + className="form-input" + /> +
+ + +
+ +
+

+ Already have an account?{' '} + +

+
+
+
+ ); +} diff --git a/german-app-frontend/src/stores/authStore.tsx b/german-app-frontend/src/stores/authStore.tsx new file mode 100644 index 0000000..c0f0278 --- /dev/null +++ b/german-app-frontend/src/stores/authStore.tsx @@ -0,0 +1,256 @@ +/** + * Auth store for managing authentication state + * Uses React context for state management + */ + +import { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import type { ReactNode } from 'react'; +import type { CurrentUser } from '@/types/api/auth'; +import * as authApi from '@/lib/api/auth'; + +// Token storage keys +const TOKEN_KEY = 'deutschlernen_token'; +const REFRESH_TOKEN_KEY = 'deutschlernen_refresh_token'; +const TOKEN_EXPIRY_KEY = 'deutschlernen_token_expiry'; + +interface AuthState { + token: string | null; + refreshToken: string | null; + user: CurrentUser | null; + isAuthenticated: boolean; + isLoading: boolean; + error: string | null; +} + +interface AuthContextType extends AuthState { + login: (email: string, password: string) => Promise; + register: (username: string, email: string, password: string) => Promise; + logout: () => void; + refreshAuthToken: () => Promise; + clearError: () => void; +} + +const AuthContext = createContext(undefined); + +// Helper to check if token is expired +function isTokenExpired(expiry: string | null): boolean { + if (!expiry) return true; + const expiryDate = new Date(expiry); + // Add 30 second buffer + return expiryDate.getTime() - 30000 < Date.now(); +} + +// Helper to get token from storage +function getTokenFromStorage(): { token: string | null; refreshToken: string | null; expiry: string | null } { + const token = localStorage.getItem(TOKEN_KEY); + const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); + const expiry = localStorage.getItem(TOKEN_EXPIRY_KEY); + return { token, refreshToken, expiry }; +} + +// Helper to store token +function storeToken(token: string, refreshToken: string, expiry: string): void { + localStorage.setItem(TOKEN_KEY, token); + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + localStorage.setItem(TOKEN_EXPIRY_KEY, expiry); +} + +// Helper to clear token +function clearToken(): void { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(TOKEN_EXPIRY_KEY); +} + +// Provider component +interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [state, setState] = useState({ + token: null, + refreshToken: null, + user: null, + isAuthenticated: false, + isLoading: true, + error: null, + }); + + // Initialize from storage + useEffect(() => { + const initializeAuth = async () => { + const { token, refreshToken, expiry } = getTokenFromStorage(); + + if (!token || isTokenExpired(expiry)) { + clearToken(); + setState({ + token: null, + refreshToken: null, + user: null, + isAuthenticated: false, + isLoading: false, + error: null, + }); + return; + } + + try { + // Try to get current user + const user = await authApi.getCurrentUser(token); + setState({ + token, + refreshToken, + user, + isAuthenticated: true, + isLoading: false, + error: null, + }); + } catch (err) { + clearToken(); + setState({ + token: null, + refreshToken: null, + user: null, + isAuthenticated: false, + isLoading: false, + error: null, + }); + } + }; + + initializeAuth(); + }, []); + + // Login function + const login = useCallback(async (email: string, password: string) => { + setState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const response = await authApi.login({ email, password }); + storeToken(response.token, response.refreshToken, response.expiresAt); + + // Get current user info + const user = await authApi.getCurrentUser(response.token); + + setState({ + token: response.token, + refreshToken: response.refreshToken, + user, + isAuthenticated: true, + isLoading: false, + error: null, + }); + } catch (err) { + setState(prev => ({ + ...prev, + isLoading: false, + error: err instanceof Error ? err.message : 'Login failed', + })); + throw err; + } + }, []); + + // Register function + const register = useCallback(async (username: string, email: string, password: string) => { + setState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const response = await authApi.register({ username, email, password }); + storeToken(response.token, response.refreshToken, response.expiresAt); + + // Get current user info + const user = await authApi.getCurrentUser(response.token); + + setState({ + token: response.token, + refreshToken: response.refreshToken, + user, + isAuthenticated: true, + isLoading: false, + error: null, + }); + } catch (err) { + setState(prev => ({ + ...prev, + isLoading: false, + error: err instanceof Error ? err.message : 'Registration failed', + })); + throw err; + } + }, []); + + // Logout function + const logout = useCallback(() => { + clearToken(); + setState({ + token: null, + refreshToken: null, + user: null, + isAuthenticated: false, + isLoading: false, + error: null, + }); + }, []); + + // Refresh token function + const refreshAuthToken = useCallback(async () => { + const { refreshToken } = getTokenFromStorage(); + + if (!refreshToken) { + logout(); + return; + } + + try { + const response = await authApi.refreshToken({ refreshToken }); + storeToken(response.token, response.refreshToken, response.expiresAt); + + // Update state with new token + setState(prev => ({ + ...prev, + token: response.token, + refreshToken: response.refreshToken, + })); + } catch (err) { + logout(); + } + }, [logout]); + + // Clear error function + const clearError = useCallback(() => { + setState(prev => ({ ...prev, error: null })); + }, []); + + const value: AuthContextType = { + ...state, + login, + register, + logout, + refreshAuthToken, + clearError, + }; + + return {children}; +} + +// Hook to use auth context +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} + +// Helper hook to check if user is admin +export function useIsAdmin(): boolean { + const { user } = useAuth(); + return user?.role === 'Admin'; +} + +// Helper to get auth header +export function getAuthHeader(token: string | null): Record { + if (!token) return {}; + return { Authorization: `Bearer ${token}` }; +} diff --git a/german-app-frontend/src/types/api/auth.ts b/german-app-frontend/src/types/api/auth.ts new file mode 100644 index 0000000..c7d1abc --- /dev/null +++ b/german-app-frontend/src/types/api/auth.ts @@ -0,0 +1,62 @@ +/** + * Auth API types for the DeutschLernen frontend + * Mirrors the backend DTOs in GermanApp/Application/DTOs/Auth/ + */ + +// Register request +export interface RegisterRequest { + username: string; + email: string; + password: string; +} + +// Login request +export interface LoginRequest { + email: string; + password: string; +} + +// Auth response (from backend) +export interface AuthResponse { + userId: number; + username: string; + email: string; + token: string; + refreshToken: string; + expiresAt: string; // ISO date string +} + +// Current user info +export interface CurrentUser { + userId: number; + username: string; + email: string; + role: string; + currentLevel: string; + streak: number; + totalPoints: number; +} + +// Token refresh request +export interface RefreshTokenRequest { + refreshToken: string; +} + +// Token refresh response +export interface RefreshTokenResponse { + token: string; + refreshToken: string; + expiresAt: string; // ISO date string +} + +// User profile with role +export interface UserProfile { + id: number; + username: string; + email: string; + role: string; + currentLevel: string; + streak: number; + totalPoints: number; + createdAt: string; // ISO date string +} diff --git a/german-app-frontend/tsconfig.app.json b/german-app-frontend/tsconfig.app.json index 7f42e5f..7e35721 100644 --- a/german-app-frontend/tsconfig.app.json +++ b/german-app-frontend/tsconfig.app.json @@ -19,7 +19,14 @@ "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + + /* Path aliases */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "ignoreDeprecations": "6.0" }, "include": ["src"] } diff --git a/german-app-frontend/vite.config.ts b/german-app-frontend/vite.config.ts index 8b0f57b..b7b70f8 100644 --- a/german-app-frontend/vite.config.ts +++ b/german-app-frontend/vite.config.ts @@ -1,7 +1,13 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import path from 'path' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, })