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 <vibe@mistral.ai>
This commit is contained in:
parent
be692886a9
commit
50f1b8a8dc
33 changed files with 3327 additions and 83 deletions
89
GermanApp/Application/DTOs/Admin/AdminUserDto.cs
Normal file
89
GermanApp/Application/DTOs/Admin/AdminUserDto.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
namespace GermanApp.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for admin user information.
|
||||
/// </summary>
|
||||
public record AdminUserDto(
|
||||
int Id,
|
||||
string Username,
|
||||
string Email,
|
||||
string Role,
|
||||
string CurrentLevel,
|
||||
int Streak,
|
||||
int TotalPoints,
|
||||
DateTime CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// DTO for user progress report.
|
||||
/// </summary>
|
||||
public record UserProgressReportDto(
|
||||
int UserId,
|
||||
string Username,
|
||||
string Email,
|
||||
string CurrentLevel,
|
||||
int TotalLessonsCompleted,
|
||||
int TotalQuizzesCompleted,
|
||||
double AverageQuizScore,
|
||||
int TotalPoints,
|
||||
int CurrentStreak,
|
||||
DateTime LastActivityDate);
|
||||
|
||||
/// <summary>
|
||||
/// DTO for lesson completion status.
|
||||
/// </summary>
|
||||
public record LessonCompletionDto(
|
||||
int LessonId,
|
||||
string LessonTitle,
|
||||
string LevelCode,
|
||||
bool IsCompleted,
|
||||
DateTime? CompletedAt);
|
||||
|
||||
/// <summary>
|
||||
/// DTO for user quiz result (for admin reports).
|
||||
/// </summary>
|
||||
public record UserQuizResultDto(
|
||||
int QuizId,
|
||||
string QuizTitle,
|
||||
int Score,
|
||||
int PassingScore,
|
||||
bool Passed,
|
||||
DateTime AttemptDate);
|
||||
|
||||
/// <summary>
|
||||
/// DTO for creating a new story via admin.
|
||||
/// </summary>
|
||||
public record AdminCreateStoryDto(
|
||||
int LevelId,
|
||||
string Theme,
|
||||
int SegmentCount = 3,
|
||||
bool GenerateAudio = false);
|
||||
|
||||
/// <summary>
|
||||
/// DTO for admin dashboard statistics.
|
||||
/// </summary>
|
||||
public record AdminDashboardStatsDto(
|
||||
int TotalUsers,
|
||||
int TotalLessons,
|
||||
int TotalQuizzes,
|
||||
int TotalStorySegments,
|
||||
int ActiveUsersThisWeek,
|
||||
double AverageUserProgress);
|
||||
|
||||
/// <summary>
|
||||
/// DTO for user list item (for admin user management).
|
||||
/// </summary>
|
||||
public record AdminUserListItemDto(
|
||||
int Id,
|
||||
string Username,
|
||||
string Email,
|
||||
string Role,
|
||||
string CurrentLevel,
|
||||
int TotalPoints,
|
||||
int Streak,
|
||||
DateTime CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// DTO for updating user role (admin only).
|
||||
/// </summary>
|
||||
public record UpdateUserRoleDto(
|
||||
string Role);
|
||||
53
GermanApp/Application/Interfaces/IAdminService.cs
Normal file
53
GermanApp/Application/Interfaces/IAdminService.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
using GermanApp.Application.DTOs;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GermanApp.Application.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for admin services.
|
||||
/// Part of the Application layer.
|
||||
/// </summary>
|
||||
public interface IAdminService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all users for admin view.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of all users</returns>
|
||||
Task<IReadOnlyList<AdminUserListItemDto>> GetAllUsersAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific user by ID.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>The user DTO</returns>
|
||||
Task<AdminUserDto?> GetUserByIdAsync(int userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a user's role.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="newRole">The new role</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>The updated user DTO</returns>
|
||||
Task<AdminUserDto?> UpdateUserRoleAsync(int userId, string newRole, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a user (admin only, cannot delete self).
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID to delete</param>
|
||||
/// <param name="adminUserId">The admin user ID making the request</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>True if successful</returns>
|
||||
Task<bool> DeleteUserAsync(int userId, int adminUserId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets admin dashboard statistics.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Dashboard statistics</returns>
|
||||
Task<AdminDashboardStatsDto> GetDashboardStatsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
@ -42,4 +42,18 @@ public interface IAuthService
|
|||
/// </summary>
|
||||
/// <param name="refreshToken">The refresh token to revoke</param>
|
||||
Task RevokeRefreshTokenAsync(string refreshToken);
|
||||
|
||||
/// <summary>
|
||||
/// Creates the first admin user (bootstrap).
|
||||
/// This is a special method that creates an admin user without requiring authentication.
|
||||
/// </summary>
|
||||
/// <param name="registerDto">Admin user registration data</param>
|
||||
/// <returns>Authentication response with token</returns>
|
||||
Task<AuthResponse> CreateAdminUserAsync(RegisterDto registerDto);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an admin user already exists.
|
||||
/// </summary>
|
||||
/// <returns>True if admin user exists, false otherwise</returns>
|
||||
Task<bool> AdminUserExistsAsync();
|
||||
}
|
||||
|
|
|
|||
51
GermanApp/Application/Interfaces/IUserReportService.cs
Normal file
51
GermanApp/Application/Interfaces/IUserReportService.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
using GermanApp.Application.DTOs;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GermanApp.Application.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for user progress report services.
|
||||
/// Part of the Application layer.
|
||||
/// </summary>
|
||||
public interface IUserReportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a progress report for a specific user.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>User progress report</returns>
|
||||
Task<UserProgressReportDto> GenerateUserReportAsync(int userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generates progress reports for all users.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of all user progress reports</returns>
|
||||
Task<IReadOnlyList<UserProgressReportDto>> GenerateAllUserReportsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets lesson completion data for a user.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of lesson completion data</returns>
|
||||
Task<IReadOnlyList<LessonCompletionDto>> GetUserLessonProgressAsync(int userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets quiz results for a user.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of quiz results</returns>
|
||||
Task<IReadOnlyList<UserQuizResultDto>> GetUserQuizResultsAsync(int userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Exports user reports as CSV.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>CSV content as string</returns>
|
||||
Task<string> ExportReportsAsCsvAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
196
GermanApp/Application/Services/AdminService.cs
Normal file
196
GermanApp/Application/Services/AdminService.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Service for admin operations.
|
||||
/// Part of the Application layer.
|
||||
/// </summary>
|
||||
public class AdminService : IAdminService
|
||||
{
|
||||
private readonly IRepository<User, int> _userRepository;
|
||||
private readonly ILevelRepository _levelRepository;
|
||||
private readonly ILessonRepository _lessonRepository;
|
||||
private readonly IQuizRepository _quizRepository;
|
||||
private readonly IStoryRepository _storyRepository;
|
||||
private readonly IUserProgressRepository _userProgressRepository;
|
||||
|
||||
public AdminService(
|
||||
IRepository<User, int> userRepository,
|
||||
ILevelRepository levelRepository,
|
||||
ILessonRepository lessonRepository,
|
||||
IQuizRepository quizRepository,
|
||||
IStoryRepository storyRepository,
|
||||
IUserProgressRepository userProgressRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_levelRepository = levelRepository;
|
||||
_lessonRepository = lessonRepository;
|
||||
_quizRepository = quizRepository;
|
||||
_storyRepository = storyRepository;
|
||||
_userProgressRepository = userProgressRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all users for admin view.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of all users</returns>
|
||||
public async Task<IReadOnlyList<AdminUserListItemDto>> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific user by ID.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>The user DTO</returns>
|
||||
public async Task<AdminUserDto?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a user's role.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="newRole">The new role</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>The updated user DTO</returns>
|
||||
public async Task<AdminUserDto?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a user (admin only, cannot delete self).
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID to delete</param>
|
||||
/// <param name="adminUserId">The admin user ID making the request</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>True if successful</returns>
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets admin dashboard statistics.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Dashboard statistics</returns>
|
||||
public async Task<AdminDashboardStatsDto> 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);
|
||||
}
|
||||
}
|
||||
189
GermanApp/Application/Services/UserReportService.cs
Normal file
189
GermanApp/Application/Services/UserReportService.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating user progress reports.
|
||||
/// Part of the Application layer.
|
||||
/// </summary>
|
||||
public class UserReportService : IUserReportService
|
||||
{
|
||||
private readonly IRepository<User, int> _userRepository;
|
||||
private readonly IUserProgressRepository _userProgressRepository;
|
||||
private readonly ILessonRepository _lessonRepository;
|
||||
private readonly ILevelRepository _levelRepository;
|
||||
private readonly IQuizRepository _quizRepository;
|
||||
private readonly IStoryProgressRepository _storyProgressRepository;
|
||||
|
||||
public UserReportService(
|
||||
IRepository<User, int> userRepository,
|
||||
IUserProgressRepository userProgressRepository,
|
||||
ILessonRepository lessonRepository,
|
||||
ILevelRepository levelRepository,
|
||||
IQuizRepository quizRepository,
|
||||
IStoryProgressRepository storyProgressRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_userProgressRepository = userProgressRepository;
|
||||
_lessonRepository = lessonRepository;
|
||||
_levelRepository = levelRepository;
|
||||
_quizRepository = quizRepository;
|
||||
_storyProgressRepository = storyProgressRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a progress report for a specific user.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>User progress report</returns>
|
||||
public async Task<UserProgressReportDto> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates progress reports for all users.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of all user progress reports</returns>
|
||||
public async Task<IReadOnlyList<UserProgressReportDto>> GenerateAllUserReportsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var users = await _userRepository.GetAllAsync(cancellationToken);
|
||||
var reports = new List<UserProgressReportDto>();
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
var report = await GenerateUserReportAsync(user.Id, cancellationToken);
|
||||
reports.Add(report);
|
||||
}
|
||||
|
||||
return reports;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets lesson completion data for a user.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of lesson completion data</returns>
|
||||
public async Task<IReadOnlyList<LessonCompletionDto>> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets quiz results for a user.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of quiz results</returns>
|
||||
public async Task<IReadOnlyList<UserQuizResultDto>> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports user reports as CSV.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>CSV content as string</returns>
|
||||
public async Task<string> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes a value for CSV output.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to escape</param>
|
||||
/// <returns>Escaped value</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns admin role to user (bootstrap only).
|
||||
/// </summary>
|
||||
public void AssignAdminRole()
|
||||
{
|
||||
Role = "Admin";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the user's role.
|
||||
/// </summary>
|
||||
/// <param name="role">The role to assign (User or Admin)</param>
|
||||
public void SetRole(string role)
|
||||
{
|
||||
if (role != "Admin" && role != "User")
|
||||
throw new ArgumentException("Role must be 'Admin' or 'User'.", nameof(role));
|
||||
Role = role;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if user has admin role.
|
||||
/// </summary>
|
||||
public bool IsAdmin() => Role == "Admin";
|
||||
|
||||
// Navigation properties
|
||||
public virtual ICollection<StoryProgress> StoryProgress { get; private set; } = new List<StoryProgress>();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
676
GermanApp/Infrastructure/Data/Migrations/20260614101057_AddRoleToUser.Designer.cs
generated
Normal file
676
GermanApp/Infrastructure/Data/Migrations/20260614101057_AddRoleToUser.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,676 @@
|
|||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int>("LevelId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("LevelId1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime?>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int>("LessonId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("PassingScore")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(80);
|
||||
|
||||
b.Property<bool>("ShuffleQuestions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int>("TimeLimitMinutes")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LessonId");
|
||||
|
||||
b.ToTable("Quizzes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("IsCorrect")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<int>("Order")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<int>("QuizQuestionId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("CorrectAnswer")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Difficulty")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(3);
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int>("Order")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<int>("Points")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("QuestionText")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<int>("QuizId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.StoryProgress", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsCompleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<int>("LevelId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("StorySegmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("UnlockedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AudioUrl")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EstimatedReadingMinutes")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(2);
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int?>("LessonId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("LessonId1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("LevelId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("LevelId1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Theme")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime?>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CurrentLevel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasDefaultValue("A1");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("User");
|
||||
|
||||
b.Property<int>("Streak")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<int>("TotalPoints")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("IsCompleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTime>("LastAttemptDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("LessonId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("QuizScore")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<int>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GermanApp.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRoleToUser : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Role",
|
||||
table: "Users",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "User");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Role",
|
||||
table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -438,6 +438,13 @@ namespace GermanApp.Migrations
|
|||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("User");
|
||||
|
||||
b.Property<int>("Streak")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
|
|
|
|||
59
GermanApp/Infrastructure/Data/Repositories/UserRepository.cs
Normal file
59
GermanApp/Infrastructure/Data/Repositories/UserRepository.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Entity Framework Core implementation of IRepository<User, int>.
|
||||
/// This is part of the Infrastructure layer.
|
||||
/// </summary>
|
||||
public class UserRepository : IRepository<User, int>
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public UserRepository(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<User?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Users
|
||||
.Include(u => u.StoryProgress)
|
||||
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<User>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Users
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<User> 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<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Users
|
||||
.AnyAsync(u => u.Id == id, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="registerDto">Admin user registration data</param>
|
||||
/// <returns>Authentication response with token</returns>
|
||||
public async Task<AuthResponse> 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)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an admin user already exists.
|
||||
/// </summary>
|
||||
/// <returns>True if admin user exists, false otherwise</returns>
|
||||
public async Task<bool> AdminUserExistsAsync()
|
||||
{
|
||||
return await _dbContext.Users.AnyAsync(u => u.Role == "Admin");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
269
GermanApp/Presentation/Controllers/AdminController.cs
Normal file
269
GermanApp/Presentation/Controllers/AdminController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// API controller for admin operations.
|
||||
/// All endpoints require Admin role.
|
||||
/// This is part of the Presentation layer.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public class AdminController : ControllerBase
|
||||
{
|
||||
private readonly IAdminService _adminService;
|
||||
private readonly IUserReportService _reportService;
|
||||
|
||||
public AdminController(IAdminService adminService, IUserReportService reportService)
|
||||
{
|
||||
_adminService = adminService;
|
||||
_reportService = reportService;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// USER MANAGEMENT ENDPOINTS
|
||||
// ============================================
|
||||
|
||||
/// <summary>
|
||||
/// Gets all users (Admin only).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of all users</returns>
|
||||
[HttpGet("users")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<AdminUserListItemDto>), (int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
||||
public async Task<IActionResult> GetAllUsersAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var users = await _adminService.GetAllUsersAsync(cancellationToken);
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific user by ID (Admin only).
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>The user DTO</returns>
|
||||
[HttpGet("users/{userId}")]
|
||||
[ProducesResponseType(typeof(AdminUserDto), (int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
||||
public async Task<IActionResult> GetUserByIdAsync(int userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _adminService.GetUserByIdAsync(userId, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a user's role (Admin only).
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="dto">The role update data</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>The updated user DTO</returns>
|
||||
[HttpPut("users/{userId}/role")]
|
||||
[ProducesResponseType(typeof(AdminUserDto), (int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
||||
public async Task<IActionResult> UpdateUserRoleAsync(
|
||||
int userId,
|
||||
[FromBody] UpdateUserRoleDto dto,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await _adminService.UpdateUserRoleAsync(userId, dto.Role, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(user);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a user (Admin only, cannot delete self).
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID to delete</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Success or error</returns>
|
||||
[HttpDelete("users/{userId}")]
|
||||
[ProducesResponseType((int)HttpStatusCode.NoContent)]
|
||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
||||
public async Task<IActionResult> DeleteUserAsync(int userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
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
|
||||
// ============================================
|
||||
|
||||
/// <summary>
|
||||
/// Gets admin dashboard statistics.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Dashboard statistics</returns>
|
||||
[HttpGet("dashboard/stats")]
|
||||
[ProducesResponseType(typeof(AdminDashboardStatsDto), (int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
||||
public async Task<IActionResult> GetDashboardStatsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stats = await _adminService.GetDashboardStatsAsync(cancellationToken);
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// REPORT ENDPOINTS
|
||||
// ============================================
|
||||
|
||||
/// <summary>
|
||||
/// Generates a progress report for a specific user.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>User progress report</returns>
|
||||
[HttpGet("reports/users/{userId}")]
|
||||
[ProducesResponseType(typeof(UserProgressReportDto), (int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
||||
public async Task<IActionResult> GetUserReportAsync(int userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var report = await _reportService.GenerateUserReportAsync(userId, cancellationToken);
|
||||
return Ok(report);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates progress reports for all users.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of all user progress reports</returns>
|
||||
[HttpGet("reports/all-users")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<UserProgressReportDto>), (int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
||||
public async Task<IActionResult> GetAllUserReportsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var reports = await _reportService.GenerateAllUserReportsAsync(cancellationToken);
|
||||
return Ok(reports);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets lesson completion data for a user.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of lesson completion data</returns>
|
||||
[HttpGet("reports/users/{userId}/lessons")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<LessonCompletionDto>), (int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
||||
public async Task<IActionResult> GetUserLessonProgressAsync(int userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var progress = await _reportService.GetUserLessonProgressAsync(userId, cancellationToken);
|
||||
return Ok(progress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets quiz results for a user.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of quiz results</returns>
|
||||
[HttpGet("reports/users/{userId}/quizzes")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<UserQuizResultDto>), (int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
||||
public async Task<IActionResult> GetUserQuizResultsAsync(int userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = await _reportService.GetUserQuizResultsAsync(userId, cancellationToken);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports all user reports as CSV.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>CSV content</returns>
|
||||
[HttpGet("reports/export/csv")]
|
||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
||||
public async Task<IActionResult> ExportReportsAsCsvAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var csv = await _reportService.ExportReportsAsCsvAsync(cancellationToken);
|
||||
return Ok(new { CsvContent = csv });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific user's full report (combined data).
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Combined user report data</returns>
|
||||
[HttpGet("reports/users/{userId}/full")]
|
||||
[ProducesResponseType((int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
||||
public async Task<IActionResult> GetUserFullReportAsync(int userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var progressReportTask = _reportService.GenerateUserReportAsync(userId, cancellationToken);
|
||||
var lessonProgressTask = _reportService.GetUserLessonProgressAsync(userId, cancellationToken);
|
||||
var quizResultsTask = _reportService.GetUserQuizResultsAsync(userId, cancellationToken);
|
||||
|
||||
await Task.WhenAll(progressReportTask, lessonProgressTask, quizResultsTask);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ProgressReport = await progressReportTask,
|
||||
LessonProgress = await lessonProgressTask,
|
||||
QuizResults = await quizResultsTask
|
||||
});
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
76
GermanApp/Presentation/Controllers/BootstrapController.cs
Normal file
76
GermanApp/Presentation/Controllers/BootstrapController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class BootstrapController : ControllerBase
|
||||
{
|
||||
private readonly IAuthService _authService;
|
||||
|
||||
public BootstrapController(IAuthService authService)
|
||||
{
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="registerDto">Admin user registration data</param>
|
||||
/// <returns>Authentication response with JWT token</returns>
|
||||
[HttpPost("admin")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.Conflict)]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an admin user already exists.
|
||||
/// </summary>
|
||||
/// <returns>True if admin exists, false otherwise</returns>
|
||||
[HttpGet("admin-exists")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(bool), (int)HttpStatusCode.OK)]
|
||||
public async Task<IActionResult> CheckAdminExists()
|
||||
{
|
||||
try
|
||||
{
|
||||
var exists = await _authService.AdminUserExistsAsync();
|
||||
return Ok(exists);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode((int)HttpStatusCode.InternalServerError, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,6 @@ public class LessonsController : ControllerBase
|
|||
/// </summary>
|
||||
/// <returns>List of all lessons</returns>
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetAllLessonsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lessons = await _lessonService.GetAllLessonsAsync(cancellationToken);
|
||||
|
|
@ -40,7 +39,6 @@ public class LessonsController : ControllerBase
|
|||
/// </summary>
|
||||
/// <returns>List of all lessons in order</returns>
|
||||
[HttpGet("ordered")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetAllOrderedLessonsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lessons = await _lessonService.GetAllOrderedLessonsAsync(cancellationToken);
|
||||
|
|
@ -53,7 +51,6 @@ public class LessonsController : ControllerBase
|
|||
/// <param name="id">The lesson ID</param>
|
||||
/// <returns>The lesson with the specified ID</returns>
|
||||
[HttpGet("{id}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetLessonByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lesson = await _lessonService.GetLessonByIdAsync(id, cancellationToken);
|
||||
|
|
@ -68,7 +65,6 @@ public class LessonsController : ControllerBase
|
|||
/// <param name="levelId">The level ID</param>
|
||||
/// <returns>List of lessons for the specified level</returns>
|
||||
[HttpGet("by-level/{levelId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetLessonsByLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lessons = await _lessonService.GetLessonsByLevelAsync(levelId, cancellationToken);
|
||||
|
|
@ -80,7 +76,6 @@ public class LessonsController : ControllerBase
|
|||
/// </summary>
|
||||
/// <returns>List of beginner lessons</returns>
|
||||
[HttpGet("beginner")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lessons = await _lessonService.GetBeginnerLessonsAsync(cancellationToken);
|
||||
|
|
@ -92,7 +87,6 @@ public class LessonsController : ControllerBase
|
|||
/// </summary>
|
||||
/// <returns>List of advanced lessons</returns>
|
||||
[HttpGet("advanced")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lessons = await _lessonService.GetAdvancedLessonsAsync(cancellationToken);
|
||||
|
|
@ -149,7 +143,6 @@ public class LessonsController : ControllerBase
|
|||
/// <param name="levelId">The level ID</param>
|
||||
/// <returns>The first lesson in the level</returns>
|
||||
[HttpGet("first/{levelId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lesson = await _lessonService.GetFirstLessonInLevelAsync(levelId, cancellationToken);
|
||||
|
|
@ -164,7 +157,6 @@ public class LessonsController : ControllerBase
|
|||
/// <param name="currentLessonId">The current lesson ID</param>
|
||||
/// <returns>The next lesson</returns>
|
||||
[HttpGet("next/{currentLessonId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lesson = await _lessonService.GetNextLessonAsync(currentLessonId, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ public class LevelsController : ControllerBase
|
|||
/// </summary>
|
||||
/// <returns>List of all levels</returns>
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetAllLevelsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var levels = await _levelService.GetAllLevelsAsync(cancellationToken);
|
||||
|
|
@ -39,7 +38,6 @@ public class LevelsController : ControllerBase
|
|||
/// <param name="id">The level ID</param>
|
||||
/// <returns>The level with the specified ID</returns>
|
||||
[HttpGet("{id}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetLevelByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var level = await _levelService.GetLevelByIdAsync(id, cancellationToken);
|
||||
|
|
@ -54,7 +52,6 @@ public class LevelsController : ControllerBase
|
|||
/// <param name="code">The level code</param>
|
||||
/// <returns>The level with the specified code</returns>
|
||||
[HttpGet("by-code/{code}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetLevelByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var level = await _levelService.GetLevelByCodeAsync(code, cancellationToken);
|
||||
|
|
@ -112,7 +109,6 @@ public class LevelsController : ControllerBase
|
|||
/// </summary>
|
||||
/// <returns>The first level</returns>
|
||||
[HttpGet("first")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetFirstLevelAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var level = await _levelService.GetFirstLevelAsync(cancellationToken);
|
||||
|
|
@ -127,7 +123,6 @@ public class LevelsController : ControllerBase
|
|||
/// <param name="currentLevelId">The current level ID</param>
|
||||
/// <returns>The next level</returns>
|
||||
[HttpGet("next/{currentLevelId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var level = await _levelService.GetNextLevelAsync(currentLevelId, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ public class QuizQuestionsController : ControllerBase
|
|||
/// </summary>
|
||||
/// <returns>List of all quiz questions</returns>
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionService.GetAllQuizQuestionsAsync(cancellationToken);
|
||||
|
|
@ -40,7 +39,6 @@ public class QuizQuestionsController : ControllerBase
|
|||
/// <param name="quizId">The quiz ID</param>
|
||||
/// <returns>List of quiz questions for the specified quiz</returns>
|
||||
[HttpGet("by-quiz/{quizId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
||||
|
|
@ -53,7 +51,6 @@ public class QuizQuestionsController : ControllerBase
|
|||
/// <param name="lessonId">The lesson ID</param>
|
||||
/// <returns>List of quiz questions for the specified lesson</returns>
|
||||
[HttpGet("by-lesson/{lessonId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionService.GetActiveQuizQuestionsByLessonAsync(lessonId, cancellationToken);
|
||||
|
|
@ -66,7 +63,6 @@ public class QuizQuestionsController : ControllerBase
|
|||
/// <param name="id">The quiz question ID</param>
|
||||
/// <returns>The quiz question with the specified ID</returns>
|
||||
[HttpGet("{id}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var question = await _quizQuestionService.GetQuizQuestionByIdAsync(id, cancellationToken);
|
||||
|
|
@ -82,7 +78,6 @@ public class QuizQuestionsController : ControllerBase
|
|||
/// <param name="count">Number of questions to return</param>
|
||||
/// <returns>List of random quiz questions for the quiz</returns>
|
||||
[HttpGet("random/{quizId}/{count}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken);
|
||||
|
|
@ -96,7 +91,6 @@ public class QuizQuestionsController : ControllerBase
|
|||
/// <param name="count">Number of questions to return</param>
|
||||
/// <returns>List of random quiz questions for the lesson</returns>
|
||||
[HttpGet("random/by-lesson/{lessonId}/{count}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionService.GetRandomQuizQuestionsForLessonAsync(lessonId, count, cancellationToken);
|
||||
|
|
@ -109,7 +103,6 @@ public class QuizQuestionsController : ControllerBase
|
|||
/// <param name="difficulty">The difficulty level (1-5)</param>
|
||||
/// <returns>List of quiz questions with the specified difficulty</returns>
|
||||
[HttpGet("by-difficulty/{difficulty}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionService.GetQuizQuestionsByDifficultyAsync(difficulty, cancellationToken);
|
||||
|
|
@ -122,7 +115,6 @@ public class QuizQuestionsController : ControllerBase
|
|||
/// <param name="type">The question type</param>
|
||||
/// <returns>List of quiz questions with the specified type</returns>
|
||||
[HttpGet("by-type/{type}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionService.GetQuizQuestionsByTypeAsync(type, cancellationToken);
|
||||
|
|
@ -179,7 +171,6 @@ public class QuizQuestionsController : ControllerBase
|
|||
/// <param name="questionId">The question ID</param>
|
||||
/// <returns>List of quiz options for the question</returns>
|
||||
[HttpGet("options/{questionId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = await _quizQuestionService.GetQuizOptionsByQuestionAsync(questionId, cancellationToken);
|
||||
|
|
@ -192,7 +183,6 @@ public class QuizQuestionsController : ControllerBase
|
|||
/// <param name="questionId">The question ID</param>
|
||||
/// <returns>The correct option for the question</returns>
|
||||
[HttpGet("correct-option/{questionId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var option = await _quizQuestionService.GetCorrectOptionAsync(questionId, cancellationToken);
|
||||
|
|
@ -207,7 +197,6 @@ public class QuizQuestionsController : ControllerBase
|
|||
/// <param name="questionId">The question ID</param>
|
||||
/// <returns>List of correct options for the question</returns>
|
||||
[HttpGet("correct-options/{questionId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = await _quizQuestionService.GetCorrectOptionsAsync(questionId, cancellationToken);
|
||||
|
|
@ -281,7 +270,6 @@ public class QuizQuestionsController : ControllerBase
|
|||
/// <param name="lessonId">The lesson ID</param>
|
||||
/// <returns>The count of quiz questions for the lesson</returns>
|
||||
[HttpGet("count/{lessonId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var count = await _quizQuestionService.GetQuizQuestionCountForLessonAsync(lessonId, cancellationToken);
|
||||
|
|
@ -294,7 +282,6 @@ public class QuizQuestionsController : ControllerBase
|
|||
/// <param name="lessonId">The lesson ID</param>
|
||||
/// <returns>The difficulty distribution for the lesson</returns>
|
||||
[HttpGet("difficulty-distribution/{lessonId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var distribution = await _quizQuestionService.GetDifficultyDistributionAsync(lessonId, cancellationToken);
|
||||
|
|
@ -307,7 +294,6 @@ public class QuizQuestionsController : ControllerBase
|
|||
/// <param name="lessonId">The lesson ID</param>
|
||||
/// <returns>The type distribution for the lesson</returns>
|
||||
[HttpGet("type-distribution/{lessonId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var distribution = await _quizQuestionService.GetTypeDistributionAsync(lessonId, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ public class QuizzesController : ControllerBase
|
|||
/// </summary>
|
||||
/// <returns>List of all quizzes</returns>
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetAllQuizzesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var quizzes = await _quizService.GetAllQuizzesAsync(cancellationToken);
|
||||
|
|
@ -43,7 +42,6 @@ public class QuizzesController : ControllerBase
|
|||
/// </summary>
|
||||
/// <returns>List of quiz summary items</returns>
|
||||
[HttpGet("list")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetAllQuizListItemsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var quizzes = await _quizService.GetAllQuizListItemsAsync(cancellationToken);
|
||||
|
|
@ -56,7 +54,6 @@ public class QuizzesController : ControllerBase
|
|||
/// <param name="id">The quiz ID</param>
|
||||
/// <returns>The quiz with the specified ID</returns>
|
||||
[HttpGet("{id}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var quiz = await _quizService.GetQuizByIdAsync(id, cancellationToken);
|
||||
|
|
@ -71,7 +68,6 @@ public class QuizzesController : ControllerBase
|
|||
/// <param name="id">The quiz ID</param>
|
||||
/// <returns>The quiz with questions</returns>
|
||||
[HttpGet("{id}/with-questions")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizWithQuestionsAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var quiz = await _quizService.GetQuizWithQuestionsAsync(id, cancellationToken);
|
||||
|
|
@ -86,7 +82,6 @@ public class QuizzesController : ControllerBase
|
|||
/// <param name="lessonId">The lesson ID</param>
|
||||
/// <returns>List of quizzes for the specified lesson</returns>
|
||||
[HttpGet("by-lesson/{lessonId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var quizzes = await _quizService.GetQuizzesByLessonAsync(lessonId, cancellationToken);
|
||||
|
|
@ -99,7 +94,6 @@ public class QuizzesController : ControllerBase
|
|||
/// <param name="lessonId">The lesson ID</param>
|
||||
/// <returns>List of active quizzes for the specified lesson</returns>
|
||||
[HttpGet("active/by-lesson/{lessonId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetActiveQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var quizzes = await _quizService.GetActiveQuizzesByLessonAsync(lessonId, cancellationToken);
|
||||
|
|
@ -112,7 +106,6 @@ public class QuizzesController : ControllerBase
|
|||
/// <param name="lessonId">The lesson ID</param>
|
||||
/// <returns>The first quiz for the lesson</returns>
|
||||
[HttpGet("first/by-lesson/{lessonId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetFirstQuizByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var quiz = await _quizService.GetFirstQuizByLessonAsync(lessonId, cancellationToken);
|
||||
|
|
@ -126,7 +119,6 @@ public class QuizzesController : ControllerBase
|
|||
/// </summary>
|
||||
/// <returns>List of active quizzes</returns>
|
||||
[HttpGet("active")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetActiveQuizzesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var quizzes = await _quizService.GetActiveQuizzesAsync(cancellationToken);
|
||||
|
|
@ -213,7 +205,6 @@ public class QuizzesController : ControllerBase
|
|||
/// <param name="id">The quiz ID</param>
|
||||
/// <returns>True if the quiz exists</returns>
|
||||
[HttpGet("exists/{id}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var exists = await _quizService.ExistsAsync(id, cancellationToken);
|
||||
|
|
@ -226,7 +217,6 @@ public class QuizzesController : ControllerBase
|
|||
/// <param name="lessonId">The lesson ID</param>
|
||||
/// <returns>True if a quiz exists for the lesson</returns>
|
||||
[HttpGet("exists/by-lesson/{lessonId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var exists = await _quizService.ExistsByLessonAsync(lessonId, cancellationToken);
|
||||
|
|
@ -238,7 +228,6 @@ public class QuizzesController : ControllerBase
|
|||
/// </summary>
|
||||
/// <returns>The total count of quizzes</returns>
|
||||
[HttpGet("count")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetTotalQuizCountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var count = await _quizService.GetTotalQuizCountAsync(cancellationToken);
|
||||
|
|
@ -251,7 +240,6 @@ public class QuizzesController : ControllerBase
|
|||
/// <param name="lessonId">The lesson ID</param>
|
||||
/// <returns>The count of quizzes for the lesson</returns>
|
||||
[HttpGet("count/by-lesson/{lessonId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizCountByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var count = await _quizService.GetQuizCountByLessonAsync(lessonId, cancellationToken);
|
||||
|
|
@ -265,7 +253,6 @@ public class QuizzesController : ControllerBase
|
|||
/// <param name="maxScore">Maximum passing score</param>
|
||||
/// <returns>List of quizzes in the score range</returns>
|
||||
[HttpGet("by-passing-score/{minScore}/{maxScore}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizzesByPassingScoreRangeAsync(int minScore, int maxScore, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var quizzes = await _quizService.GetQuizzesByPassingScoreRangeAsync(minScore, maxScore, cancellationToken);
|
||||
|
|
@ -278,7 +265,6 @@ public class QuizzesController : ControllerBase
|
|||
/// <param name="quizId">The quiz ID</param>
|
||||
/// <returns>List of quiz questions for the quiz</returns>
|
||||
[HttpGet("{quizId}/questions")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionService.GetQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
||||
|
|
@ -291,7 +277,6 @@ public class QuizzesController : ControllerBase
|
|||
/// <param name="quizId">The quiz ID</param>
|
||||
/// <returns>List of active quiz questions for the quiz</returns>
|
||||
[HttpGet("{quizId}/questions/active")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetActiveQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
||||
|
|
@ -305,7 +290,6 @@ public class QuizzesController : ControllerBase
|
|||
/// <param name="count">Number of questions to return</param>
|
||||
/// <returns>List of random quiz questions</returns>
|
||||
[HttpGet("{quizId}/questions/random/{count}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken);
|
||||
|
|
@ -335,7 +319,6 @@ public class QuizzesController : ControllerBase
|
|||
/// <param name="quizId">The quiz ID</param>
|
||||
/// <returns>The total points for the quiz</returns>
|
||||
[HttpGet("{quizId}/total-points")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var points = await _quizQuestionService.GetTotalPointsForQuizAsync(quizId, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ try
|
|||
});
|
||||
|
||||
// Register repositories (Infrastructure implementations of Domain interfaces)
|
||||
builder.Services.AddScoped<IRepository<User, int>, UserRepository>();
|
||||
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
|
||||
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
|
||||
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
|
||||
|
|
@ -235,6 +236,11 @@ try
|
|||
// Register Story services
|
||||
builder.Services.AddScoped<StoryService>();
|
||||
builder.Services.AddScoped<StoryUnlockService>();
|
||||
|
||||
// Register Admin services
|
||||
builder.Services.AddScoped<IAdminService, AdminService>();
|
||||
builder.Services.AddScoped<IUserReportService, UserReportService>();
|
||||
|
||||
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
|
||||
|
||||
// Note: QuizQuestionService requires IQuizRepository, ILessonRepository, and ProgressService which are registered above
|
||||
|
|
|
|||
2
german-app-frontend/.env.development
Normal file
2
german-app-frontend/.env.development
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Development environment variables
|
||||
VITE_API_URL=http://localhost:5000
|
||||
|
|
@ -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 (
|
||||
<div className="home-page">
|
||||
<h1>DeutschLernen</h1>
|
||||
<p>German Learning Application</p>
|
||||
<nav className="nav-links">
|
||||
<Link to="/story/1">A1 Story</Link>
|
||||
<Link to="/story/2">A2 Story</Link>
|
||||
<Link to="/story/3">B1 Story</Link>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 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 (
|
||||
<Router>
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<Link to="/" className="app-title">
|
||||
<h1>DeutschLernen</h1>
|
||||
</Link>
|
||||
<nav className="app-nav">
|
||||
<Link to="/">Home</Link>
|
||||
<Link to="/story/1">Stories</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<Link to="/" className="app-title">
|
||||
<h1>DeutschLernen</h1>
|
||||
</Link>
|
||||
<nav className="app-nav">
|
||||
<Link to="/">Home</Link>
|
||||
<Link to="/story/1">Stories</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/story/:levelId" element={<StoryPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<main className="app-main">{children}</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
<p>© {new Date().getFullYear()} DeutschLernen</p>
|
||||
</footer>
|
||||
</div>
|
||||
</Router>
|
||||
<footer className="app-footer">
|
||||
<p>© {new Date().getFullYear()} DeutschLernen</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* Public routes (no authentication required) */}
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
|
||||
{/* Protected routes (require authentication) */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout>
|
||||
<HomePage />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/story/:levelId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout>
|
||||
<StoryPage />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin routes (require authentication + admin role) */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AppLayout>
|
||||
<div className="admin-page">
|
||||
<h1>Admin Dashboard</h1>
|
||||
<p>Admin content goes here</p>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Catch-all route */}
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="loading-overlay">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={redirectTo} state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="loading-overlay">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
// Redirect to home if authenticated but not admin
|
||||
if (user?.role !== 'Admin') {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
152
german-app-frontend/src/lib/api/auth.ts
Normal file
152
german-app-frontend/src/lib/api/auth.ts
Normal file
|
|
@ -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<AuthResponse> {
|
||||
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<AuthResponse> {
|
||||
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<CurrentUser> {
|
||||
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<RefreshTokenResponse> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<AuthResponse> {
|
||||
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();
|
||||
}
|
||||
105
german-app-frontend/src/pages/HomePage.tsx
Normal file
105
german-app-frontend/src/pages/HomePage.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="home-page">
|
||||
<header className="home-header">
|
||||
<h1>Welcome to DeutschLernen</h1>
|
||||
<p className="welcome-message">
|
||||
Hello, {user?.username}! You're currently at level {user?.currentLevel}.
|
||||
</p>
|
||||
<p className="user-stats">
|
||||
Points: {user?.totalPoints} | Streak: {user?.streak} days
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="home-content">
|
||||
<section className="content-section">
|
||||
<h2>Continue Learning</h2>
|
||||
<div className="quick-access">
|
||||
<button
|
||||
onClick={() => navigate('/lessons')}
|
||||
className="quick-access-card"
|
||||
>
|
||||
<span className="card-icon">📚</span>
|
||||
<span className="card-title">Lessons</span>
|
||||
<span className="card-description">Practice vocabulary and grammar</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/story/1')}
|
||||
className="quick-access-card"
|
||||
>
|
||||
<span className="card-icon">📖</span>
|
||||
<span className="card-title">Stories</span>
|
||||
<span className="card-description">Read engaging German stories</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/quizzes')}
|
||||
className="quick-access-card"
|
||||
>
|
||||
<span className="card-icon">🎯</span>
|
||||
<span className="card-title">Quizzes</span>
|
||||
<span className="card-description">Test your knowledge</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content-section">
|
||||
<h2>Your Progress</h2>
|
||||
<div className="progress-overview">
|
||||
<div className="progress-card">
|
||||
<h3>Level {user?.currentLevel}</h3>
|
||||
<p>Current CEFR level</p>
|
||||
</div>
|
||||
<div className="progress-card">
|
||||
<h3>{user?.totalPoints} pts</h3>
|
||||
<p>Total points earned</p>
|
||||
</div>
|
||||
<div className="progress-card">
|
||||
<h3>{user?.streak} days</h3>
|
||||
<p>Current learning streak</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isAdmin && (
|
||||
<section className="content-section admin-section">
|
||||
<h2>Admin Dashboard</h2>
|
||||
<p>You have administrator access to manage users and content.</p>
|
||||
<div className="admin-actions">
|
||||
<button
|
||||
onClick={() => navigate('/admin')}
|
||||
className="btn btn-admin"
|
||||
>
|
||||
Go to Admin Panel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="content-section">
|
||||
<div className="user-actions">
|
||||
<button
|
||||
onClick={() => logout()}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
german-app-frontend/src/pages/LandingPage.tsx
Normal file
85
german-app-frontend/src/pages/LandingPage.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="loading-overlay">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="landing-page">
|
||||
<div className="landing-hero">
|
||||
<h1 className="landing-title">DeutschLernen</h1>
|
||||
<p className="landing-subtitle">
|
||||
Learn German with interactive stories, lessons, and quizzes
|
||||
</p>
|
||||
<p className="landing-description">
|
||||
Join our community and track your progress through CEFR levels A1 to C1.
|
||||
All learning content requires registration and sign-in.
|
||||
</p>
|
||||
|
||||
<div className="landing-actions">
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="btn btn-primary btn-large"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/register')}
|
||||
className="btn btn-secondary btn-large"
|
||||
>
|
||||
Create Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="landing-features">
|
||||
<h2>What You Get</h2>
|
||||
<div className="features-grid">
|
||||
<div className="feature-card">
|
||||
<h3>📚 Interactive Lessons</h3>
|
||||
<p>Learn German vocabulary and grammar through structured lessons</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<h3>📖 Engaging Stories</h3>
|
||||
<p>Read German stories at your level with audio support</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<h3>🎯 Progress Tracking</h3>
|
||||
<p>Track your completion of lessons, quizzes, and stories</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<h3>✅ Personalized Learning</h3>
|
||||
<p>Your progress is saved individually across all devices</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="landing-footer">
|
||||
<p>All content requires authentication. No public access available.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
german-app-frontend/src/pages/LoginPage.tsx
Normal file
100
german-app-frontend/src/pages/LoginPage.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-title">Login</h1>
|
||||
<p className="auth-subtitle">Sign in to access your German learning content</p>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="Enter your email"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Enter your password"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="btn btn-primary btn-full"
|
||||
>
|
||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-links">
|
||||
<p>
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
onClick={() => navigate('/register')}
|
||||
className="link-btn"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
german-app-frontend/src/pages/RegisterPage.tsx
Normal file
150
german-app-frontend/src/pages/RegisterPage.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-title">Create Account</h1>
|
||||
<p className="auth-subtitle">Join DeutschLernen and start your German learning journey</p>
|
||||
|
||||
{(error || validationError) && (
|
||||
<div className="error-message">
|
||||
{error || validationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
placeholder="Enter your username"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="Enter your email"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="Enter your password (min 8 characters)"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Confirm your password"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="btn btn-primary btn-full"
|
||||
>
|
||||
{isSubmitting ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-links">
|
||||
<p>
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="link-btn"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
german-app-frontend/src/stores/authStore.tsx
Normal file
256
german-app-frontend/src/stores/authStore.tsx
Normal file
|
|
@ -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<void>;
|
||||
register: (username: string, email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshAuthToken: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(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<AuthState>({
|
||||
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
// 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<string, string> {
|
||||
if (!token) return {};
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
62
german-app-frontend/src/types/api/auth.ts
Normal file
62
german-app-frontend/src/types/api/auth.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -19,7 +19,14 @@
|
|||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue