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:
Lasse Rune Hansen 2026-06-14 12:42:15 +02:00
parent be692886a9
commit 50f1b8a8dc
33 changed files with 3327 additions and 83 deletions

View 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);

View 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);
}

View file

@ -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();
} }

View 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);
}

View 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);
}
}

View 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;
}
}

View file

@ -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>();
} }

View file

@ -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();

View 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
}
}
}

View file

@ -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");
}
}
}

View file

@ -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")

View 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);
}
}

View file

@ -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");
}
} }

View 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);
}
}
}

View 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);
}
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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

View file

@ -0,0 +1,2 @@
# Development environment variables
VITE_API_URL=http://localhost:5000

View file

@ -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>
); );
} }

View file

@ -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}</>;
}

View file

@ -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;
}
}

View 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();
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}` };
}

View 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
}

View file

@ -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"]
} }

View file

@ -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'),
},
},
}) })