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>
|
/// </summary>
|
||||||
/// <param name="refreshToken">The refresh token to revoke</param>
|
/// <param name="refreshToken">The refresh token to revoke</param>
|
||||||
Task RevokeRefreshTokenAsync(string refreshToken);
|
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 Email { get; private set; } = string.Empty;
|
||||||
public string PasswordHash { get; private set; } = string.Empty;
|
public string PasswordHash { get; private set; } = string.Empty;
|
||||||
public string CurrentLevel { get; private set; } = "A1";
|
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 Streak { get; private set; }
|
||||||
public int TotalPoints { get; private set; }
|
public int TotalPoints { get; private set; }
|
||||||
public DateTime CreatedAt { get; private set; }
|
public DateTime CreatedAt { get; private set; }
|
||||||
|
|
@ -76,6 +77,30 @@ public class User
|
||||||
PasswordHash = newPasswordHash;
|
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
|
// Navigation properties
|
||||||
public virtual ICollection<StoryProgress> StoryProgress { get; private set; } = new List<StoryProgress>();
|
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.Email).IsRequired().HasMaxLength(100);
|
||||||
builder.Property(u => u.PasswordHash).IsRequired().HasMaxLength(255);
|
builder.Property(u => u.PasswordHash).IsRequired().HasMaxLength(255);
|
||||||
builder.Property(u => u.CurrentLevel).HasMaxLength(10).HasDefaultValue("A1");
|
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.Streak).HasDefaultValue(0);
|
||||||
builder.Property(u => u.TotalPoints).HasDefaultValue(0);
|
builder.Property(u => u.TotalPoints).HasDefaultValue(0);
|
||||||
builder.Property(u => u.CreatedAt).IsRequired();
|
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)
|
.HasMaxLength(255)
|
||||||
.HasColumnType("character varying(255)");
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("User");
|
||||||
|
|
||||||
b.Property<int>("Streak")
|
b.Property<int>("Streak")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("integer")
|
.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.NameIdentifier, user.Id.ToString()),
|
||||||
new Claim(ClaimTypes.Name, user.Username),
|
new Claim(ClaimTypes.Name, user.Username),
|
||||||
new Claim(ClaimTypes.Email, user.Email),
|
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(
|
var token = new JwtSecurityToken(
|
||||||
|
|
@ -227,4 +227,64 @@ public class AuthService : IAuthService
|
||||||
storedToken.Revoke();
|
storedToken.Revoke();
|
||||||
await _dbContext.SaveChangesAsync();
|
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>
|
/// </summary>
|
||||||
/// <returns>List of all lessons</returns>
|
/// <returns>List of all lessons</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetAllLessonsAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetAllLessonsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lessons = await _lessonService.GetAllLessonsAsync(cancellationToken);
|
var lessons = await _lessonService.GetAllLessonsAsync(cancellationToken);
|
||||||
|
|
@ -40,7 +39,6 @@ public class LessonsController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of all lessons in order</returns>
|
/// <returns>List of all lessons in order</returns>
|
||||||
[HttpGet("ordered")]
|
[HttpGet("ordered")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetAllOrderedLessonsAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetAllOrderedLessonsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lessons = await _lessonService.GetAllOrderedLessonsAsync(cancellationToken);
|
var lessons = await _lessonService.GetAllOrderedLessonsAsync(cancellationToken);
|
||||||
|
|
@ -53,7 +51,6 @@ public class LessonsController : ControllerBase
|
||||||
/// <param name="id">The lesson ID</param>
|
/// <param name="id">The lesson ID</param>
|
||||||
/// <returns>The lesson with the specified ID</returns>
|
/// <returns>The lesson with the specified ID</returns>
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetLessonByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetLessonByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lesson = await _lessonService.GetLessonByIdAsync(id, cancellationToken);
|
var lesson = await _lessonService.GetLessonByIdAsync(id, cancellationToken);
|
||||||
|
|
@ -68,7 +65,6 @@ public class LessonsController : ControllerBase
|
||||||
/// <param name="levelId">The level ID</param>
|
/// <param name="levelId">The level ID</param>
|
||||||
/// <returns>List of lessons for the specified level</returns>
|
/// <returns>List of lessons for the specified level</returns>
|
||||||
[HttpGet("by-level/{levelId}")]
|
[HttpGet("by-level/{levelId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetLessonsByLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetLessonsByLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lessons = await _lessonService.GetLessonsByLevelAsync(levelId, cancellationToken);
|
var lessons = await _lessonService.GetLessonsByLevelAsync(levelId, cancellationToken);
|
||||||
|
|
@ -80,7 +76,6 @@ public class LessonsController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of beginner lessons</returns>
|
/// <returns>List of beginner lessons</returns>
|
||||||
[HttpGet("beginner")]
|
[HttpGet("beginner")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lessons = await _lessonService.GetBeginnerLessonsAsync(cancellationToken);
|
var lessons = await _lessonService.GetBeginnerLessonsAsync(cancellationToken);
|
||||||
|
|
@ -92,7 +87,6 @@ public class LessonsController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of advanced lessons</returns>
|
/// <returns>List of advanced lessons</returns>
|
||||||
[HttpGet("advanced")]
|
[HttpGet("advanced")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lessons = await _lessonService.GetAdvancedLessonsAsync(cancellationToken);
|
var lessons = await _lessonService.GetAdvancedLessonsAsync(cancellationToken);
|
||||||
|
|
@ -149,7 +143,6 @@ public class LessonsController : ControllerBase
|
||||||
/// <param name="levelId">The level ID</param>
|
/// <param name="levelId">The level ID</param>
|
||||||
/// <returns>The first lesson in the level</returns>
|
/// <returns>The first lesson in the level</returns>
|
||||||
[HttpGet("first/{levelId}")]
|
[HttpGet("first/{levelId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lesson = await _lessonService.GetFirstLessonInLevelAsync(levelId, cancellationToken);
|
var lesson = await _lessonService.GetFirstLessonInLevelAsync(levelId, cancellationToken);
|
||||||
|
|
@ -164,7 +157,6 @@ public class LessonsController : ControllerBase
|
||||||
/// <param name="currentLessonId">The current lesson ID</param>
|
/// <param name="currentLessonId">The current lesson ID</param>
|
||||||
/// <returns>The next lesson</returns>
|
/// <returns>The next lesson</returns>
|
||||||
[HttpGet("next/{currentLessonId}")]
|
[HttpGet("next/{currentLessonId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lesson = await _lessonService.GetNextLessonAsync(currentLessonId, cancellationToken);
|
var lesson = await _lessonService.GetNextLessonAsync(currentLessonId, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ public class LevelsController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of all levels</returns>
|
/// <returns>List of all levels</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetAllLevelsAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetAllLevelsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var levels = await _levelService.GetAllLevelsAsync(cancellationToken);
|
var levels = await _levelService.GetAllLevelsAsync(cancellationToken);
|
||||||
|
|
@ -39,7 +38,6 @@ public class LevelsController : ControllerBase
|
||||||
/// <param name="id">The level ID</param>
|
/// <param name="id">The level ID</param>
|
||||||
/// <returns>The level with the specified ID</returns>
|
/// <returns>The level with the specified ID</returns>
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetLevelByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetLevelByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level = await _levelService.GetLevelByIdAsync(id, cancellationToken);
|
var level = await _levelService.GetLevelByIdAsync(id, cancellationToken);
|
||||||
|
|
@ -54,7 +52,6 @@ public class LevelsController : ControllerBase
|
||||||
/// <param name="code">The level code</param>
|
/// <param name="code">The level code</param>
|
||||||
/// <returns>The level with the specified code</returns>
|
/// <returns>The level with the specified code</returns>
|
||||||
[HttpGet("by-code/{code}")]
|
[HttpGet("by-code/{code}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetLevelByCodeAsync(string code, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetLevelByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level = await _levelService.GetLevelByCodeAsync(code, cancellationToken);
|
var level = await _levelService.GetLevelByCodeAsync(code, cancellationToken);
|
||||||
|
|
@ -112,7 +109,6 @@ public class LevelsController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>The first level</returns>
|
/// <returns>The first level</returns>
|
||||||
[HttpGet("first")]
|
[HttpGet("first")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetFirstLevelAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetFirstLevelAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level = await _levelService.GetFirstLevelAsync(cancellationToken);
|
var level = await _levelService.GetFirstLevelAsync(cancellationToken);
|
||||||
|
|
@ -127,7 +123,6 @@ public class LevelsController : ControllerBase
|
||||||
/// <param name="currentLevelId">The current level ID</param>
|
/// <param name="currentLevelId">The current level ID</param>
|
||||||
/// <returns>The next level</returns>
|
/// <returns>The next level</returns>
|
||||||
[HttpGet("next/{currentLevelId}")]
|
[HttpGet("next/{currentLevelId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level = await _levelService.GetNextLevelAsync(currentLevelId, cancellationToken);
|
var level = await _levelService.GetNextLevelAsync(currentLevelId, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of all quiz questions</returns>
|
/// <returns>List of all quiz questions</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetAllQuizQuestionsAsync(cancellationToken);
|
var questions = await _quizQuestionService.GetAllQuizQuestionsAsync(cancellationToken);
|
||||||
|
|
@ -40,7 +39,6 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="quizId">The quiz ID</param>
|
/// <param name="quizId">The quiz ID</param>
|
||||||
/// <returns>List of quiz questions for the specified quiz</returns>
|
/// <returns>List of quiz questions for the specified quiz</returns>
|
||||||
[HttpGet("by-quiz/{quizId}")]
|
[HttpGet("by-quiz/{quizId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
||||||
|
|
@ -53,7 +51,6 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>List of quiz questions for the specified lesson</returns>
|
/// <returns>List of quiz questions for the specified lesson</returns>
|
||||||
[HttpGet("by-lesson/{lessonId}")]
|
[HttpGet("by-lesson/{lessonId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetActiveQuizQuestionsByLessonAsync(lessonId, cancellationToken);
|
var questions = await _quizQuestionService.GetActiveQuizQuestionsByLessonAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -66,7 +63,6 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="id">The quiz question ID</param>
|
/// <param name="id">The quiz question ID</param>
|
||||||
/// <returns>The quiz question with the specified ID</returns>
|
/// <returns>The quiz question with the specified ID</returns>
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var question = await _quizQuestionService.GetQuizQuestionByIdAsync(id, cancellationToken);
|
var question = await _quizQuestionService.GetQuizQuestionByIdAsync(id, cancellationToken);
|
||||||
|
|
@ -82,7 +78,6 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="count">Number of questions to return</param>
|
/// <param name="count">Number of questions to return</param>
|
||||||
/// <returns>List of random quiz questions for the quiz</returns>
|
/// <returns>List of random quiz questions for the quiz</returns>
|
||||||
[HttpGet("random/{quizId}/{count}")]
|
[HttpGet("random/{quizId}/{count}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken);
|
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>
|
/// <param name="count">Number of questions to return</param>
|
||||||
/// <returns>List of random quiz questions for the lesson</returns>
|
/// <returns>List of random quiz questions for the lesson</returns>
|
||||||
[HttpGet("random/by-lesson/{lessonId}/{count}")]
|
[HttpGet("random/by-lesson/{lessonId}/{count}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetRandomQuizQuestionsForLessonAsync(lessonId, count, cancellationToken);
|
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>
|
/// <param name="difficulty">The difficulty level (1-5)</param>
|
||||||
/// <returns>List of quiz questions with the specified difficulty</returns>
|
/// <returns>List of quiz questions with the specified difficulty</returns>
|
||||||
[HttpGet("by-difficulty/{difficulty}")]
|
[HttpGet("by-difficulty/{difficulty}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetQuizQuestionsByDifficultyAsync(difficulty, cancellationToken);
|
var questions = await _quizQuestionService.GetQuizQuestionsByDifficultyAsync(difficulty, cancellationToken);
|
||||||
|
|
@ -122,7 +115,6 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="type">The question type</param>
|
/// <param name="type">The question type</param>
|
||||||
/// <returns>List of quiz questions with the specified type</returns>
|
/// <returns>List of quiz questions with the specified type</returns>
|
||||||
[HttpGet("by-type/{type}")]
|
[HttpGet("by-type/{type}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetQuizQuestionsByTypeAsync(type, cancellationToken);
|
var questions = await _quizQuestionService.GetQuizQuestionsByTypeAsync(type, cancellationToken);
|
||||||
|
|
@ -179,7 +171,6 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="questionId">The question ID</param>
|
/// <param name="questionId">The question ID</param>
|
||||||
/// <returns>List of quiz options for the question</returns>
|
/// <returns>List of quiz options for the question</returns>
|
||||||
[HttpGet("options/{questionId}")]
|
[HttpGet("options/{questionId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var options = await _quizQuestionService.GetQuizOptionsByQuestionAsync(questionId, cancellationToken);
|
var options = await _quizQuestionService.GetQuizOptionsByQuestionAsync(questionId, cancellationToken);
|
||||||
|
|
@ -192,7 +183,6 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="questionId">The question ID</param>
|
/// <param name="questionId">The question ID</param>
|
||||||
/// <returns>The correct option for the question</returns>
|
/// <returns>The correct option for the question</returns>
|
||||||
[HttpGet("correct-option/{questionId}")]
|
[HttpGet("correct-option/{questionId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var option = await _quizQuestionService.GetCorrectOptionAsync(questionId, cancellationToken);
|
var option = await _quizQuestionService.GetCorrectOptionAsync(questionId, cancellationToken);
|
||||||
|
|
@ -207,7 +197,6 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="questionId">The question ID</param>
|
/// <param name="questionId">The question ID</param>
|
||||||
/// <returns>List of correct options for the question</returns>
|
/// <returns>List of correct options for the question</returns>
|
||||||
[HttpGet("correct-options/{questionId}")]
|
[HttpGet("correct-options/{questionId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var options = await _quizQuestionService.GetCorrectOptionsAsync(questionId, cancellationToken);
|
var options = await _quizQuestionService.GetCorrectOptionsAsync(questionId, cancellationToken);
|
||||||
|
|
@ -281,7 +270,6 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>The count of quiz questions for the lesson</returns>
|
/// <returns>The count of quiz questions for the lesson</returns>
|
||||||
[HttpGet("count/{lessonId}")]
|
[HttpGet("count/{lessonId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var count = await _quizQuestionService.GetQuizQuestionCountForLessonAsync(lessonId, cancellationToken);
|
var count = await _quizQuestionService.GetQuizQuestionCountForLessonAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -294,7 +282,6 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>The difficulty distribution for the lesson</returns>
|
/// <returns>The difficulty distribution for the lesson</returns>
|
||||||
[HttpGet("difficulty-distribution/{lessonId}")]
|
[HttpGet("difficulty-distribution/{lessonId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var distribution = await _quizQuestionService.GetDifficultyDistributionAsync(lessonId, cancellationToken);
|
var distribution = await _quizQuestionService.GetDifficultyDistributionAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -307,7 +294,6 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>The type distribution for the lesson</returns>
|
/// <returns>The type distribution for the lesson</returns>
|
||||||
[HttpGet("type-distribution/{lessonId}")]
|
[HttpGet("type-distribution/{lessonId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var distribution = await _quizQuestionService.GetTypeDistributionAsync(lessonId, cancellationToken);
|
var distribution = await _quizQuestionService.GetTypeDistributionAsync(lessonId, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of all quizzes</returns>
|
/// <returns>List of all quizzes</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetAllQuizzesAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetAllQuizzesAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quizzes = await _quizService.GetAllQuizzesAsync(cancellationToken);
|
var quizzes = await _quizService.GetAllQuizzesAsync(cancellationToken);
|
||||||
|
|
@ -43,7 +42,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of quiz summary items</returns>
|
/// <returns>List of quiz summary items</returns>
|
||||||
[HttpGet("list")]
|
[HttpGet("list")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetAllQuizListItemsAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetAllQuizListItemsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quizzes = await _quizService.GetAllQuizListItemsAsync(cancellationToken);
|
var quizzes = await _quizService.GetAllQuizListItemsAsync(cancellationToken);
|
||||||
|
|
@ -56,7 +54,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="id">The quiz ID</param>
|
/// <param name="id">The quiz ID</param>
|
||||||
/// <returns>The quiz with the specified ID</returns>
|
/// <returns>The quiz with the specified ID</returns>
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quiz = await _quizService.GetQuizByIdAsync(id, cancellationToken);
|
var quiz = await _quizService.GetQuizByIdAsync(id, cancellationToken);
|
||||||
|
|
@ -71,7 +68,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="id">The quiz ID</param>
|
/// <param name="id">The quiz ID</param>
|
||||||
/// <returns>The quiz with questions</returns>
|
/// <returns>The quiz with questions</returns>
|
||||||
[HttpGet("{id}/with-questions")]
|
[HttpGet("{id}/with-questions")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizWithQuestionsAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizWithQuestionsAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quiz = await _quizService.GetQuizWithQuestionsAsync(id, cancellationToken);
|
var quiz = await _quizService.GetQuizWithQuestionsAsync(id, cancellationToken);
|
||||||
|
|
@ -86,7 +82,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>List of quizzes for the specified lesson</returns>
|
/// <returns>List of quizzes for the specified lesson</returns>
|
||||||
[HttpGet("by-lesson/{lessonId}")]
|
[HttpGet("by-lesson/{lessonId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quizzes = await _quizService.GetQuizzesByLessonAsync(lessonId, cancellationToken);
|
var quizzes = await _quizService.GetQuizzesByLessonAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -99,7 +94,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>List of active quizzes for the specified lesson</returns>
|
/// <returns>List of active quizzes for the specified lesson</returns>
|
||||||
[HttpGet("active/by-lesson/{lessonId}")]
|
[HttpGet("active/by-lesson/{lessonId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetActiveQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetActiveQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quizzes = await _quizService.GetActiveQuizzesByLessonAsync(lessonId, cancellationToken);
|
var quizzes = await _quizService.GetActiveQuizzesByLessonAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -112,7 +106,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>The first quiz for the lesson</returns>
|
/// <returns>The first quiz for the lesson</returns>
|
||||||
[HttpGet("first/by-lesson/{lessonId}")]
|
[HttpGet("first/by-lesson/{lessonId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetFirstQuizByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetFirstQuizByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quiz = await _quizService.GetFirstQuizByLessonAsync(lessonId, cancellationToken);
|
var quiz = await _quizService.GetFirstQuizByLessonAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -126,7 +119,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of active quizzes</returns>
|
/// <returns>List of active quizzes</returns>
|
||||||
[HttpGet("active")]
|
[HttpGet("active")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetActiveQuizzesAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetActiveQuizzesAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quizzes = await _quizService.GetActiveQuizzesAsync(cancellationToken);
|
var quizzes = await _quizService.GetActiveQuizzesAsync(cancellationToken);
|
||||||
|
|
@ -213,7 +205,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="id">The quiz ID</param>
|
/// <param name="id">The quiz ID</param>
|
||||||
/// <returns>True if the quiz exists</returns>
|
/// <returns>True if the quiz exists</returns>
|
||||||
[HttpGet("exists/{id}")]
|
[HttpGet("exists/{id}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var exists = await _quizService.ExistsAsync(id, cancellationToken);
|
var exists = await _quizService.ExistsAsync(id, cancellationToken);
|
||||||
|
|
@ -226,7 +217,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>True if a quiz exists for the lesson</returns>
|
/// <returns>True if a quiz exists for the lesson</returns>
|
||||||
[HttpGet("exists/by-lesson/{lessonId}")]
|
[HttpGet("exists/by-lesson/{lessonId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var exists = await _quizService.ExistsByLessonAsync(lessonId, cancellationToken);
|
var exists = await _quizService.ExistsByLessonAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -238,7 +228,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>The total count of quizzes</returns>
|
/// <returns>The total count of quizzes</returns>
|
||||||
[HttpGet("count")]
|
[HttpGet("count")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetTotalQuizCountAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetTotalQuizCountAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var count = await _quizService.GetTotalQuizCountAsync(cancellationToken);
|
var count = await _quizService.GetTotalQuizCountAsync(cancellationToken);
|
||||||
|
|
@ -251,7 +240,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>The count of quizzes for the lesson</returns>
|
/// <returns>The count of quizzes for the lesson</returns>
|
||||||
[HttpGet("count/by-lesson/{lessonId}")]
|
[HttpGet("count/by-lesson/{lessonId}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizCountByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizCountByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var count = await _quizService.GetQuizCountByLessonAsync(lessonId, cancellationToken);
|
var count = await _quizService.GetQuizCountByLessonAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -265,7 +253,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="maxScore">Maximum passing score</param>
|
/// <param name="maxScore">Maximum passing score</param>
|
||||||
/// <returns>List of quizzes in the score range</returns>
|
/// <returns>List of quizzes in the score range</returns>
|
||||||
[HttpGet("by-passing-score/{minScore}/{maxScore}")]
|
[HttpGet("by-passing-score/{minScore}/{maxScore}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizzesByPassingScoreRangeAsync(int minScore, int maxScore, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizzesByPassingScoreRangeAsync(int minScore, int maxScore, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quizzes = await _quizService.GetQuizzesByPassingScoreRangeAsync(minScore, maxScore, cancellationToken);
|
var quizzes = await _quizService.GetQuizzesByPassingScoreRangeAsync(minScore, maxScore, cancellationToken);
|
||||||
|
|
@ -278,7 +265,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="quizId">The quiz ID</param>
|
/// <param name="quizId">The quiz ID</param>
|
||||||
/// <returns>List of quiz questions for the quiz</returns>
|
/// <returns>List of quiz questions for the quiz</returns>
|
||||||
[HttpGet("{quizId}/questions")]
|
[HttpGet("{quizId}/questions")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
var questions = await _quizQuestionService.GetQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
||||||
|
|
@ -291,7 +277,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="quizId">The quiz ID</param>
|
/// <param name="quizId">The quiz ID</param>
|
||||||
/// <returns>List of active quiz questions for the quiz</returns>
|
/// <returns>List of active quiz questions for the quiz</returns>
|
||||||
[HttpGet("{quizId}/questions/active")]
|
[HttpGet("{quizId}/questions/active")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetActiveQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetActiveQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
||||||
|
|
@ -305,7 +290,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="count">Number of questions to return</param>
|
/// <param name="count">Number of questions to return</param>
|
||||||
/// <returns>List of random quiz questions</returns>
|
/// <returns>List of random quiz questions</returns>
|
||||||
[HttpGet("{quizId}/questions/random/{count}")]
|
[HttpGet("{quizId}/questions/random/{count}")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken);
|
var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken);
|
||||||
|
|
@ -335,7 +319,6 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="quizId">The quiz ID</param>
|
/// <param name="quizId">The quiz ID</param>
|
||||||
/// <returns>The total points for the quiz</returns>
|
/// <returns>The total points for the quiz</returns>
|
||||||
[HttpGet("{quizId}/total-points")]
|
[HttpGet("{quizId}/total-points")]
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var points = await _quizQuestionService.GetTotalPointsForQuizAsync(quizId, cancellationToken);
|
var points = await _quizQuestionService.GetTotalPointsForQuizAsync(quizId, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,7 @@ try
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register repositories (Infrastructure implementations of Domain interfaces)
|
// Register repositories (Infrastructure implementations of Domain interfaces)
|
||||||
|
builder.Services.AddScoped<IRepository<User, int>, UserRepository>();
|
||||||
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
|
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
|
||||||
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
|
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
|
||||||
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
|
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
|
||||||
|
|
@ -235,6 +236,11 @@ try
|
||||||
// Register Story services
|
// Register Story services
|
||||||
builder.Services.AddScoped<StoryService>();
|
builder.Services.AddScoped<StoryService>();
|
||||||
builder.Services.AddScoped<StoryUnlockService>();
|
builder.Services.AddScoped<StoryUnlockService>();
|
||||||
|
|
||||||
|
// Register Admin services
|
||||||
|
builder.Services.AddScoped<IAdminService, AdminService>();
|
||||||
|
builder.Services.AddScoped<IUserReportService, UserReportService>();
|
||||||
|
|
||||||
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
|
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
|
||||||
|
|
||||||
// Note: QuizQuestionService requires IQuizRepository, ILessonRepository, and ProgressService which are registered above
|
// 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 { 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 StoryPage from './pages/StoryPage';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
function HomePage() {
|
// Re-export for easier imports
|
||||||
return (
|
export { AuthProvider } from '@/stores/authStore';
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotFoundPage() {
|
function NotFoundPage() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -26,32 +21,80 @@ function NotFoundPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
// Layout component with header and footer
|
||||||
|
function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<div className="app-container">
|
||||||
<div className="app-container">
|
<header className="app-header">
|
||||||
<header className="app-header">
|
<Link to="/" className="app-title">
|
||||||
<Link to="/" className="app-title">
|
<h1>DeutschLernen</h1>
|
||||||
<h1>DeutschLernen</h1>
|
</Link>
|
||||||
</Link>
|
<nav className="app-nav">
|
||||||
<nav className="app-nav">
|
<Link to="/">Home</Link>
|
||||||
<Link to="/">Home</Link>
|
<Link to="/story/1">Stories</Link>
|
||||||
<Link to="/story/1">Stories</Link>
|
</nav>
|
||||||
</nav>
|
</header>
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="app-main">
|
<main className="app-main">{children}</main>
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<HomePage />} />
|
|
||||||
<Route path="/story/:levelId" element={<StoryPage />} />
|
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer className="app-footer">
|
<footer className="app-footer">
|
||||||
<p>© {new Date().getFullYear()} DeutschLernen</p>
|
<p>© {new Date().getFullYear()} DeutschLernen</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
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,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Path aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"ignoreDeprecations": "6.0"
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue