Refactor: Web Consolidation and Identity Stabilization #40
@@ -39,8 +39,8 @@ This skill defines the architectural guardrails for the NexusReader project to e
|
|||||||
### 6. Database Schema Changes
|
### 6. Database Schema Changes
|
||||||
- Every change to a Domain entity or DbContext MUST be followed by the generation of a new EF Core migration.
|
- Every change to a Domain entity or DbContext MUST be followed by the generation of a new EF Core migration.
|
||||||
- **Mandatory Commands**:
|
- **Mandatory Commands**:
|
||||||
- `dotnet ef migrations add <MigrationName> --project src/NexusReader.Data --startup-project src/NexusReader.Web.New`
|
- `dotnet ef migrations add <MigrationName> --project src/NexusReader.Data --startup-project src/NexusReader.Web`
|
||||||
- `dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web.New`
|
- `dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web`
|
||||||
- Ensure the migration is applied to all local development environments before proceeding with feature verification.
|
- Ensure the migration is applied to all local development environments before proceeding with feature verification.
|
||||||
|
|
||||||
## Audit Scripts
|
## Audit Scripts
|
||||||
|
|||||||
+1
-1
@@ -29,4 +29,4 @@ Thumbs.db
|
|||||||
*.epub
|
*.epub
|
||||||
|
|
||||||
.fake
|
.fake
|
||||||
src/NexusReader.Web.New/nexus.db
|
src/NexusReader.Web/nexus.db
|
||||||
|
|||||||
+3
-3
@@ -3,20 +3,20 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
|||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Copy csproj files and restore dependencies
|
# Copy csproj files and restore dependencies
|
||||||
COPY ["src/NexusReader.Web.New/NexusReader.Web.csproj", "src/NexusReader.Web.New/"]
|
COPY ["src/NexusReader.Web/NexusReader.Web.csproj", "src/NexusReader.Web/"]
|
||||||
COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"]
|
COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"]
|
||||||
COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"]
|
COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"]
|
||||||
COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"]
|
COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"]
|
||||||
COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"]
|
COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"]
|
||||||
COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"]
|
COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"]
|
||||||
|
|
||||||
RUN dotnet restore "src/NexusReader.Web.New/NexusReader.Web.csproj"
|
RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj"
|
||||||
|
|
||||||
# Copy the rest of the source code
|
# Copy the rest of the source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build and publish
|
# Build and publish
|
||||||
WORKDIR "/src/src/NexusReader.Web.New"
|
WORKDIR "/src/src/NexusReader.Web"
|
||||||
RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
# Stage 2: Runtime
|
# Stage 2: Runtime
|
||||||
|
|||||||
+5
-2
@@ -9,7 +9,10 @@
|
|||||||
<Project Path="src/NexusReader.Data/NexusReader.Data.csproj" />
|
<Project Path="src/NexusReader.Data/NexusReader.Data.csproj" />
|
||||||
<Project Path="src/NexusReader.Maui/NexusReader.Maui.csproj" />
|
<Project Path="src/NexusReader.Maui/NexusReader.Maui.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/src/NexusReader.Web.New/">
|
<Folder Name="/src/NexusReader.Web/">
|
||||||
<Project Path="src/NexusReader.Web.New/NexusReader.Web.csproj" />
|
<Project Path="src/NexusReader.Web/NexusReader.Web.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
# Debug helper for NexusReader.Web.New (Blazor Server)
|
# Debug helper for NexusReader.Web (Blazor Server)
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
# 1️⃣ Ensure the port is free before starting the server.
|
# 1️⃣ Ensure the port is free before starting the server.
|
||||||
# 2️⃣ Starts the server project in the background.
|
# 2️⃣ Starts the server project in the background.
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
|
|
||||||
# ---- configuration ------------------------------------------------
|
# ---- configuration ------------------------------------------------
|
||||||
SERVER_PROJECT="src/NexusReader.Web.New/NexusReader.Web.csproj"
|
SERVER_PROJECT="src/NexusReader.Web/NexusReader.Web.csproj"
|
||||||
APP_URL="http://localhost:5104"
|
APP_URL="http://localhost:5104"
|
||||||
DEBUG_PORT=9222
|
DEBUG_PORT=9222
|
||||||
TMP_PROFILE="/tmp/blazor-chrome-debug"
|
TMP_PROFILE="/tmp/blazor-chrome-debug"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
|
using FluentResults;
|
||||||
|
|
||||||
namespace NexusReader.Application.Abstractions.Services;
|
namespace NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
public interface IBillingService
|
public interface IBillingService
|
||||||
{
|
{
|
||||||
Task<bool> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId);
|
Task<Result> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId);
|
||||||
Task<bool> HandleSubscriptionDeletedAsync(string customerEmail);
|
Task<Result> HandleSubscriptionDeletedAsync(string customerEmail);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Application.Queries.Reader;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
public interface IEpubMetadataExtractor
|
||||||
|
{
|
||||||
|
Task<Result<LocalEpubMetadata>> ExtractMetadataAsync(Stream epubStream);
|
||||||
|
}
|
||||||
+1
-1
@@ -3,7 +3,7 @@ using NexusReader.Application.Queries.Reader;
|
|||||||
|
|
||||||
namespace NexusReader.Application.Abstractions.Services;
|
namespace NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
public interface IEpubService
|
public interface IEpubReader
|
||||||
{
|
{
|
||||||
Task<Result<ReaderPageViewModel>> GetEpubContentAsync(int chapterIndex, string? userId = null);
|
Task<Result<ReaderPageViewModel>> GetEpubContentAsync(int chapterIndex, string? userId = null);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using NexusReader.Application.DTOs.User;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
public interface IIdentityService
|
||||||
|
{
|
||||||
|
event Func<Task>? OnStateInvalidated;
|
||||||
|
Task<Result> RegisterAsync(string email, string password);
|
||||||
|
Task<Result> LoginAsync(string email, string password, bool rememberMe = false);
|
||||||
|
Task<Result> LogoutAsync();
|
||||||
|
Task<Result<UserProfileDto>> GetProfileAsync();
|
||||||
|
Task<Result> RefreshTokenAsync();
|
||||||
|
}
|
||||||
@@ -4,13 +4,13 @@ namespace NexusReader.Application.Abstractions.Services;
|
|||||||
|
|
||||||
public interface INativeStorageService
|
public interface INativeStorageService
|
||||||
{
|
{
|
||||||
Result SaveString(string key, string value);
|
Task<Result> SaveStringAsync(string key, string value);
|
||||||
Result<string?> GetString(string key);
|
Task<Result<string?>> GetStringAsync(string key);
|
||||||
Result SaveBool(string key, bool value);
|
Task<Result> SaveBoolAsync(string key, bool value);
|
||||||
Result<bool> GetBool(string key, bool defaultValue = false);
|
Task<Result<bool>> GetBoolAsync(string key, bool defaultValue = false);
|
||||||
Result Remove(string key);
|
Task<Result> RemoveAsync(string key);
|
||||||
|
|
||||||
Task<Result> SaveSecureString(string key, string value);
|
Task<Result> SaveSecureString(string key, string value);
|
||||||
Task<Result<string?>> GetSecureString(string key);
|
Task<Result<string?>> GetSecureString(string key);
|
||||||
Result RemoveSecure(string key);
|
Task<Result> RemoveSecureAsync(string key);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
namespace NexusReader.UI.Shared.Constants;
|
namespace NexusReader.Application.Constants;
|
||||||
|
|
||||||
public static class PlanConstants
|
public static class PlanConstants
|
||||||
{
|
{
|
||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
namespace NexusReader.UI.Shared.Constants;
|
namespace NexusReader.Application.Constants;
|
||||||
|
|
||||||
public static class StorageKeys
|
public static class StorageKeys
|
||||||
{
|
{
|
||||||
@@ -6,4 +6,5 @@ public static class StorageKeys
|
|||||||
public const string RefreshToken = "nexus_refresh_token";
|
public const string RefreshToken = "nexus_refresh_token";
|
||||||
public const string UserEmail = "nexus_user_email";
|
public const string UserEmail = "nexus_user_email";
|
||||||
public const string UserTenant = "nexus_user_tenant";
|
public const string UserTenant = "nexus_user_tenant";
|
||||||
|
public const string UserRoles = "nexus_user_roles";
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using NexusReader.Application.Constants;
|
||||||
|
|
||||||
namespace NexusReader.Application.DTOs.User;
|
namespace NexusReader.Application.DTOs.User;
|
||||||
|
|
||||||
public record UserProfileDto
|
public record UserProfileDto
|
||||||
@@ -17,6 +19,13 @@ public record UserProfileDto
|
|||||||
/// Summary of the last read book.
|
/// Summary of the last read book.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public LastReadBookDto? LastReadBook { get; init; }
|
public LastReadBookDto? LastReadBook { get; init; }
|
||||||
|
|
||||||
|
public string[] Roles { get; init; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
// Helper properties for UI compatibility
|
||||||
|
public string CurrentPlan => Plan?.Name ?? PlanConstants.DefaultPlanName;
|
||||||
|
public int AITokenLimit => Plan?.AITokenLimit ?? PlanConstants.DefaultTokenLimit;
|
||||||
|
public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record LastReadBookDto
|
public record LastReadBookDto
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
using Mapster;
|
using Mapster;
|
||||||
using MapsterMapper;
|
using MapsterMapper;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System.Reflection;
|
using NexusReader.Domain.Entities;
|
||||||
|
using NexusReader.Application.DTOs.User;
|
||||||
|
|
||||||
namespace NexusReader.Application.Mappings;
|
namespace NexusReader.Application.Mappings;
|
||||||
|
|
||||||
@@ -11,8 +12,8 @@ public static class MappingConfig
|
|||||||
{
|
{
|
||||||
var config = TypeAdapterConfig.GlobalSettings;
|
var config = TypeAdapterConfig.GlobalSettings;
|
||||||
|
|
||||||
// Manual registration for AOT (or use Source Generator)
|
config.NewConfig<NexusUser, UserProfileDto>();
|
||||||
// config.NewConfig<Source, Destination>();
|
// Roles are mapped manually in queries due to Identity structure
|
||||||
|
|
||||||
services.AddSingleton(config);
|
services.AddSingleton(config);
|
||||||
services.AddScoped<IMapper, ServiceMapper>();
|
services.AddScoped<IMapper, ServiceMapper>();
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ namespace NexusReader.Application.Queries.Reader;
|
|||||||
|
|
||||||
internal sealed class GetReaderPageQueryHandler : IQueryHandler<GetReaderPageQuery, ReaderPageViewModel>
|
internal sealed class GetReaderPageQueryHandler : IQueryHandler<GetReaderPageQuery, ReaderPageViewModel>
|
||||||
{
|
{
|
||||||
private readonly IEpubService _epubService;
|
private readonly IEpubReader _epubService;
|
||||||
|
|
||||||
public GetReaderPageQueryHandler(IEpubService epubService)
|
public GetReaderPageQueryHandler(IEpubReader epubService)
|
||||||
{
|
{
|
||||||
_epubService = epubService;
|
_epubService = epubService;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace NexusReader.Application.Queries.Reader;
|
||||||
|
|
||||||
|
public record LocalEpubMetadata(
|
||||||
|
string Title,
|
||||||
|
string Author,
|
||||||
|
byte[]? CoverImage = null
|
||||||
|
);
|
||||||
@@ -48,7 +48,11 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
|||||||
Progress = e.Progress,
|
Progress = e.Progress,
|
||||||
LastChapter = e.LastChapter ?? "Rozpoczynanie...",
|
LastChapter = e.LastChapter ?? "Rozpoczynanie...",
|
||||||
LastChapterIndex = e.LastChapterIndex
|
LastChapterIndex = e.LastChapterIndex
|
||||||
}).FirstOrDefault()
|
}).FirstOrDefault(),
|
||||||
|
Roles = dbContext.UserRoles
|
||||||
|
.Where(ur => ur.UserId == u.Id)
|
||||||
|
.Join(dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r.Name!)
|
||||||
|
.ToArray()
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
|||||||
}
|
}
|
||||||
|
|
||||||
var basePath = currentDir != null
|
var basePath = currentDir != null
|
||||||
? Path.Combine(currentDir.FullName, "src", "NexusReader.Web.New")
|
? Path.Combine(currentDir.FullName, "src", "NexusReader.Web")
|
||||||
: Directory.GetCurrentDirectory();
|
: Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
var configuration = new ConfigurationBuilder()
|
var configuration = new ConfigurationBuilder()
|
||||||
|
|||||||
@@ -6,66 +6,66 @@ namespace NexusReader.Infrastructure.Mobile.Services;
|
|||||||
|
|
||||||
public sealed class MauiStorageService : INativeStorageService
|
public sealed class MauiStorageService : INativeStorageService
|
||||||
{
|
{
|
||||||
public Result SaveString(string key, string value)
|
public Task<Result> SaveStringAsync(string key, string value)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Preferences.Default.Set(key, value);
|
Preferences.Default.Set(key, value);
|
||||||
return Result.Ok();
|
return Task.FromResult(Result.Ok());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Fail(ex.Message);
|
return Task.FromResult(Result.Fail(ex.Message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result<string?> GetString(string key)
|
public Task<Result<string?>> GetStringAsync(string key)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return Result.Ok(Preferences.Default.Get(key, (string?)null));
|
return Task.FromResult(Result.Ok(Preferences.Default.Get(key, (string?)null)));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Fail(ex.Message);
|
return Task.FromResult(Result.Fail<string?>(ex.Message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result SaveBool(string key, bool value)
|
public Task<Result> SaveBoolAsync(string key, bool value)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Preferences.Default.Set(key, value);
|
Preferences.Default.Set(key, value);
|
||||||
return Result.Ok();
|
return Task.FromResult(Result.Ok());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Fail(ex.Message);
|
return Task.FromResult(Result.Fail(ex.Message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result<bool> GetBool(string key, bool defaultValue = false)
|
public Task<Result<bool>> GetBoolAsync(string key, bool defaultValue = false)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return Result.Ok(Preferences.Default.Get(key, defaultValue));
|
return Task.FromResult(Result.Ok(Preferences.Default.Get(key, defaultValue)));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Fail(ex.Message);
|
return Task.FromResult(Result.Fail<bool>(ex.Message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result Remove(string key)
|
public Task<Result> RemoveAsync(string key)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Preferences.Default.Remove(key);
|
Preferences.Default.Remove(key);
|
||||||
return Result.Ok();
|
return Task.FromResult(Result.Ok());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Fail(ex.Message);
|
return Task.FromResult(Result.Fail(ex.Message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,16 +94,16 @@ public sealed class MauiStorageService : INativeStorageService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result RemoveSecure(string key)
|
public Task<Result> RemoveSecureAsync(string key)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
SecureStorage.Default.Remove(key);
|
SecureStorage.Default.Remove(key);
|
||||||
return Result.Ok();
|
return Task.FromResult(Result.Ok());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Fail(ex.Message);
|
return Task.FromResult(Result.Fail(ex.Message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ public static class DependencyInjection
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
services.AddScoped<IKnowledgeService, KnowledgeService>();
|
services.AddScoped<IKnowledgeService, KnowledgeService>();
|
||||||
services.AddTransient<IEpubService, EpubService>();
|
services.AddTransient<IEpubReader, EpubReaderService>();
|
||||||
|
services.AddTransient<IEpubMetadataExtractor, EpubMetadataExtractor>();
|
||||||
|
|
||||||
services.AddAuthorizationCore(options =>
|
services.AddAuthorizationCore(options =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="GeminiDotnet.Extensions.AI" Version="0.23.0" />
|
<PackageReference Include="GeminiDotnet.Extensions.AI" Version="0.23.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using NexusReader.Application.Abstractions.Services;
|
|||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
using NexusReader.Infrastructure.Configuration;
|
using NexusReader.Infrastructure.Configuration;
|
||||||
using NexusReader.Data.Persistence;
|
using NexusReader.Data.Persistence;
|
||||||
|
using FluentResults;
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Services;
|
namespace NexusReader.Infrastructure.Services;
|
||||||
|
|
||||||
@@ -28,13 +29,15 @@ public class BillingService : IBillingService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId)
|
public async Task<Result> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var user = await _userManager.FindByEmailAsync(customerEmail);
|
var user = await _userManager.FindByEmailAsync(customerEmail);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Attempted to update subscription for non-existent user: {Email}", customerEmail);
|
_logger.LogWarning("Attempted to update subscription for non-existent user: {Email}", customerEmail);
|
||||||
return false;
|
return Result.Fail($"User {customerEmail} not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
string targetPlanName = SubscriptionPlan.FreeName;
|
string targetPlanName = SubscriptionPlan.FreeName;
|
||||||
@@ -68,19 +71,27 @@ public class BillingService : IBillingService
|
|||||||
{
|
{
|
||||||
_logger.LogError("Failed to update user {Email} after subscription change: {Errors}",
|
_logger.LogError("Failed to update user {Email} after subscription change: {Errors}",
|
||||||
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||||
return false;
|
return Result.Fail(result.Errors.Select(e => e.Description).FirstOrDefault() ?? "Failed to update user profile.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Unexpected error during subscription update for {Email}", customerEmail);
|
||||||
|
return Result.Fail(new Error("Unexpected error during subscription update.").CausedBy(ex));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> HandleSubscriptionDeletedAsync(string customerEmail)
|
public async Task<Result> HandleSubscriptionDeletedAsync(string customerEmail)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var user = await _userManager.FindByEmailAsync(customerEmail);
|
var user = await _userManager.FindByEmailAsync(customerEmail);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Attempted to delete subscription for non-existent user: {Email}", customerEmail);
|
_logger.LogWarning("Attempted to delete subscription for non-existent user: {Email}", customerEmail);
|
||||||
return false;
|
return Result.Fail($"User {customerEmail} not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||||
@@ -96,9 +107,15 @@ public class BillingService : IBillingService
|
|||||||
{
|
{
|
||||||
_logger.LogError("Failed to reset user {Email} to Free tier after subscription deletion: {Errors}",
|
_logger.LogError("Failed to reset user {Email} to Free tier after subscription deletion: {Errors}",
|
||||||
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||||
return false;
|
return Result.Fail(result.Errors.Select(e => e.Description).FirstOrDefault() ?? "Failed to reset user to free tier.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Unexpected error during subscription deletion for {Email}", customerEmail);
|
||||||
|
return Result.Fail(new Error("Unexpected error during subscription deletion.").CausedBy(ex));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ using NexusReader.Domain.Entities;
|
|||||||
|
|
||||||
namespace NexusReader.Infrastructure.Services;
|
namespace NexusReader.Infrastructure.Services;
|
||||||
|
|
||||||
public class EpubService : IEpubService
|
public class EpubReaderService : IEpubReader
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||||
private const string EpubPath = "wwwroot/assets/book.epub";
|
private const string EpubPath = "wwwroot/assets/book.epub";
|
||||||
private const int WordThreshold = 1000;
|
private const int WordThreshold = 1000;
|
||||||
|
|
||||||
public EpubService(IDbContextFactory<AppDbContext> dbContextFactory)
|
public EpubReaderService(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||||
{
|
{
|
||||||
_dbContextFactory = dbContextFactory;
|
_dbContextFactory = dbContextFactory;
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ public class EpubService : IEpubService
|
|||||||
while (currentDir != null)
|
while (currentDir != null)
|
||||||
{
|
{
|
||||||
var checkPath1 = Path.Combine(currentDir.FullName, relativePath);
|
var checkPath1 = Path.Combine(currentDir.FullName, relativePath);
|
||||||
var checkPath2 = Path.Combine(currentDir.FullName, "src", "NexusReader.Web.New", relativePath);
|
var checkPath2 = Path.Combine(currentDir.FullName, "src", "NexusReader.Web", relativePath);
|
||||||
|
|
||||||
searchPaths.Add(checkPath1);
|
searchPaths.Add(checkPath1);
|
||||||
if (File.Exists(checkPath1)) { fullPath = checkPath1; break; }
|
if (File.Exists(checkPath1)) { fullPath = checkPath1; break; }
|
||||||
@@ -215,4 +215,24 @@ public class EpubService : IEpubService
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// Metadata extraction moved to EpubMetadataExtractor
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EpubMetadataExtractor : IEpubMetadataExtractor
|
||||||
|
{
|
||||||
|
public async Task<Result<LocalEpubMetadata>> ExtractMetadataAsync(Stream epubStream)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var bookRef = await EpubReader.OpenBookAsync(epubStream);
|
||||||
|
var title = bookRef.Title ?? "Unknown Title";
|
||||||
|
var author = bookRef.Author ?? "Unknown Author";
|
||||||
|
byte[]? cover = await bookRef.ReadCoverAsync();
|
||||||
|
return Result.Ok(new LocalEpubMetadata(title, author, cover));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error($"Failed to extract EPUB metadata locally: {ex.Message}").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,68 +4,68 @@ using NexusReader.Application.Abstractions.Services;
|
|||||||
|
|
||||||
namespace NexusReader.Maui.Services;
|
namespace NexusReader.Maui.Services;
|
||||||
|
|
||||||
public class MauiStorageService : INativeStorageService
|
public sealed class MauiStorageService : INativeStorageService
|
||||||
{
|
{
|
||||||
public Result SaveString(string key, string value)
|
public Task<Result> SaveStringAsync(string key, string value)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Preferences.Default.Set(key, value);
|
Preferences.Default.Set(key, value);
|
||||||
return Result.Ok();
|
return Task.FromResult(Result.Ok());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Fail(ex.Message);
|
return Task.FromResult(Result.Fail(ex.Message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result<string?> GetString(string key)
|
public Task<Result<string?>> GetStringAsync(string key)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return Result.Ok(Preferences.Default.Get<string?>(key, null));
|
return Task.FromResult(Result.Ok(Preferences.Default.Get(key, (string?)null)));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Fail(ex.Message);
|
return Task.FromResult(Result.Fail<string?>(ex.Message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result SaveBool(string key, bool value)
|
public Task<Result> SaveBoolAsync(string key, bool value)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Preferences.Default.Set(key, value);
|
Preferences.Default.Set(key, value);
|
||||||
return Result.Ok();
|
return Task.FromResult(Result.Ok());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Fail(ex.Message);
|
return Task.FromResult(Result.Fail(ex.Message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result<bool> GetBool(string key, bool defaultValue = false)
|
public Task<Result<bool>> GetBoolAsync(string key, bool defaultValue = false)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return Result.Ok(Preferences.Default.Get(key, defaultValue));
|
return Task.FromResult(Result.Ok(Preferences.Default.Get(key, defaultValue)));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Fail(ex.Message);
|
return Task.FromResult(Result.Fail<bool>(ex.Message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result Remove(string key)
|
public Task<Result> RemoveAsync(string key)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Preferences.Default.Remove(key);
|
Preferences.Default.Remove(key);
|
||||||
return Result.Ok();
|
return Task.FromResult(Result.Ok());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Fail(ex.Message);
|
return Task.FromResult(Result.Fail(ex.Message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +86,7 @@ public class MauiStorageService : INativeStorageService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var value = await SecureStorage.Default.GetAsync(key);
|
return Result.Ok(await SecureStorage.Default.GetAsync(key));
|
||||||
return Result.Ok(value);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -95,16 +94,16 @@ public class MauiStorageService : INativeStorageService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result RemoveSecure(string key)
|
public Task<Result> RemoveSecureAsync(string key)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
SecureStorage.Default.Remove(key);
|
SecureStorage.Default.Remove(key);
|
||||||
return Result.Ok();
|
return Task.FromResult(Result.Ok());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Result.Fail(ex.Message);
|
return Task.FromResult(Result.Fail(ex.Message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,8 @@
|
|||||||
? FullPageContent
|
? FullPageContent
|
||||||
: $"[ID: {ContextBlockId}]\n{Dialogue}";
|
: $"[ID: {ContextBlockId}]\n{Dialogue}";
|
||||||
|
|
||||||
_packet = await Coordinator.RequestSummaryAndQuizAsync(contentToAnalyze);
|
var result = await Coordinator.RequestSummaryAndQuizAsync(contentToAnalyze);
|
||||||
|
_packet = result.IsSuccess ? result.Value : null;
|
||||||
|
|
||||||
var summary = _packet?.Summary;
|
var summary = _packet?.Summary;
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
private async Task HandleLogout()
|
private async Task HandleLogout()
|
||||||
{
|
{
|
||||||
await IdentityService.LogoutAsync();
|
await IdentityService.LogoutAsync();
|
||||||
NavigationManager.NavigateTo("/", true);
|
NavigationManager.NavigateTo("/account/logout-form", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||||
|
|||||||
@@ -80,7 +80,8 @@
|
|||||||
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
|
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
Packet = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{SelectedText}");
|
var result = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{SelectedText}");
|
||||||
|
Packet = result.IsSuccess ? result.Value : null;
|
||||||
IsLoading = false;
|
IsLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using NexusReader.Application.Abstractions.Services
|
||||||
|
@using NexusReader.Application.Queries.Reader
|
||||||
|
@inject IEpubMetadataExtractor MetadataExtractor
|
||||||
|
@inject ILogger<BookIngestionModal> Logger
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
@if (IsOpen)
|
||||||
|
{
|
||||||
|
<div class="modal-backdrop" @onclick="CloseModal">
|
||||||
|
<div class="modal-content glass-panel" @onclick:stopPropagation>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Add New Book</h2>
|
||||||
|
<button class="close-btn" @onclick="CloseModal">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="parsing-state shimmer" style="@(IsParsing ? "display:flex;" : "display:none;")">
|
||||||
|
<div class="shimmer-content">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Scanning metadata...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata-state" style="@(Metadata != null && !IsParsing ? "display:flex;" : "display:none;")">
|
||||||
|
@if (Metadata != null)
|
||||||
|
{
|
||||||
|
<div class="metadata-info">
|
||||||
|
<h3>@Metadata.Title</h3>
|
||||||
|
<p class="author">@Metadata.Author</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary">Confirm & Upload</button>
|
||||||
|
<button class="btn btn-secondary" @onclick="Reset">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-state @(_isDragging ? "drag-over" : "")"
|
||||||
|
style="@(!IsParsing && Metadata == null ? "display:flex;" : "display:none;")"
|
||||||
|
@ondragenter="OnDragEnter"
|
||||||
|
@ondragleave="OnDragLeave">
|
||||||
|
<div class="drop-zone">
|
||||||
|
<InputFile id="epub-upload" OnChange="HandleFileSelected" accept=".epub" class="file-input-cover" />
|
||||||
|
<div class="drop-zone-content">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
|
||||||
|
<p>Drag and drop your .epub file here</p>
|
||||||
|
<span>or click to browse</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="error-message">
|
||||||
|
@ErrorMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the modal is open.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public bool IsOpen { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event triggered when the IsOpen state changes.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<bool> IsOpenChanged { get; set; }
|
||||||
|
|
||||||
|
private bool _isDragging;
|
||||||
|
private bool IsParsing { get; set; }
|
||||||
|
private LocalEpubMetadata? Metadata { get; set; }
|
||||||
|
private string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
// Allow up to 50 MB
|
||||||
|
private const long MaxFileSize = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
private async Task CloseModal()
|
||||||
|
{
|
||||||
|
IsOpen = false;
|
||||||
|
Reset();
|
||||||
|
await IsOpenChanged.InvokeAsync(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Reset()
|
||||||
|
{
|
||||||
|
IsParsing = false;
|
||||||
|
Metadata = null;
|
||||||
|
ErrorMessage = null;
|
||||||
|
_isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDragEnter() => _isDragging = true;
|
||||||
|
private void OnDragLeave() => _isDragging = false;
|
||||||
|
|
||||||
|
private async Task HandleFileSelected(InputFileChangeEventArgs e)
|
||||||
|
{
|
||||||
|
_isDragging = false;
|
||||||
|
var file = e.File;
|
||||||
|
|
||||||
|
if (file == null) return;
|
||||||
|
|
||||||
|
if (!file.Name.EndsWith(".epub", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
ErrorMessage = "Only .epub files are supported.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorMessage = null;
|
||||||
|
IsParsing = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = file.OpenReadStream(MaxFileSize);
|
||||||
|
|
||||||
|
// In Blazor WASM, we might need to copy to memory stream first for synchronous parsing if the parser doesn't stream well over interop
|
||||||
|
using var memoryStream = new MemoryStream();
|
||||||
|
await stream.CopyToAsync(memoryStream);
|
||||||
|
memoryStream.Position = 0;
|
||||||
|
|
||||||
|
var result = await MetadataExtractor.ExtractMetadataAsync(memoryStream);
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
Metadata = result.Value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ErrorMessage = result.Errors.FirstOrDefault()?.Message ?? "Failed to parse EPUB.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Error uploading EPUB");
|
||||||
|
ErrorMessage = $"An unexpected error occurred: {ex.Message} \n {ex.StackTrace}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsParsing = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
// Cleanup if necessary
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: linear-gradient(145deg, #1a1a1a 0%, #0a0a0a 100%);
|
||||||
|
border: 1px solid rgba(0, 255, 153, 0.2);
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5), 0 0 20px rgba(0, 255, 153, 0.05);
|
||||||
|
border-radius: 20px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--nexus-font-sans);
|
||||||
|
color: var(--nexus-text);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--nexus-text-muted, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: var(--nexus-neon, #00ffaa);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
min-height: 250px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload State */
|
||||||
|
.upload-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone {
|
||||||
|
flex: 1;
|
||||||
|
border: 2px dashed rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone:hover, .upload-state.drag-over .drop-zone {
|
||||||
|
border-color: var(--nexus-accent, #00ffaa);
|
||||||
|
background: rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--nexus-text-muted, #888);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-content svg {
|
||||||
|
color: var(--nexus-accent, #00ffaa);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-content p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--nexus-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone ::deep .file-input-cover {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parsing State */
|
||||||
|
.parsing-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 50%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.05), transparent);
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid rgba(0, 255, 153, 0.1);
|
||||||
|
border-top-color: var(--nexus-neon, #00ffaa);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(0, 255, 153, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.parsing-state p {
|
||||||
|
color: var(--nexus-text);
|
||||||
|
font-family: var(--nexus-font-mono, monospace);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metadata State */
|
||||||
|
.metadata-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-info {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-info h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--nexus-text);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-info .author {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--nexus-text-muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-family: var(--nexus-font-sans);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--nexus-neon, #00ffaa);
|
||||||
|
color: #050505;
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #00e699;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--nexus-text);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: #ff5555;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
100% { left: 200%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.modal-backdrop,
|
||||||
|
.shimmer::before,
|
||||||
|
.spinner {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
ViewModel = result.Value;
|
ViewModel = result.Value;
|
||||||
NavigationService.UpdateMetadata(ViewModel.CurrentChapterIndex, ViewModel.TotalChapters, ViewModel.ChapterTitle);
|
await NavigationService.UpdateMetadataAsync(ViewModel.CurrentChapterIndex, ViewModel.TotalChapters, ViewModel.ChapterTitle);
|
||||||
|
|
||||||
// Trigger full page graph generation after loading
|
// Trigger full page graph generation after loading
|
||||||
await Coordinator.ProcessFullPageAsync(GetFullPageContent());
|
await Coordinator.ProcessFullPageAsync(GetFullPageContent());
|
||||||
|
|||||||
@@ -101,6 +101,6 @@
|
|||||||
private async Task HandleLogout()
|
private async Task HandleLogout()
|
||||||
{
|
{
|
||||||
await IdentityService.LogoutAsync();
|
await IdentityService.LogoutAsync();
|
||||||
NavigationManager.NavigateTo("/", true);
|
NavigationManager.NavigateTo("/account/logout-form", true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
@using NexusReader.UI.Shared.Components.Atoms
|
@using NexusReader.UI.Shared.Components.Atoms
|
||||||
@inject IIdentityService IdentityService
|
@inject IIdentityService IdentityService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<div class="login-page-container">
|
<div class="login-page-container">
|
||||||
<div class="mesh-bg"></div>
|
<div class="mesh-bg"></div>
|
||||||
@@ -90,6 +91,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form id="nexusLoginForm" method="post" action="/account/login-form" style="display:none">
|
||||||
|
<input type="hidden" name="email" value="@_loginModel.Email" />
|
||||||
|
<input type="hidden" name="password" value="@_loginModel.Password" />
|
||||||
|
<input type="hidden" name="rememberMe" value="@(_loginModel.RememberMe ? "true" : "false")" />
|
||||||
|
</form>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter]
|
[Parameter]
|
||||||
[SupplyParameterFromQuery(Name = "error")]
|
[SupplyParameterFromQuery(Name = "error")]
|
||||||
@@ -125,7 +132,8 @@
|
|||||||
var result = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password, _loginModel.RememberMe);
|
var result = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password, _loginModel.RememberMe);
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
NavigationManager.NavigateTo("/");
|
// Trigger hidden form submission to perform cookie-based sign-in
|
||||||
|
await JS.InvokeVoidAsync("eval", "document.getElementById('nexusLoginForm').submit()");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private UserProfile? _profile;
|
private UserProfileDto? _profile;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -133,6 +133,6 @@
|
|||||||
private async Task HandleLogout()
|
private async Task HandleLogout()
|
||||||
{
|
{
|
||||||
await IdentityService.LogoutAsync();
|
await IdentityService.LogoutAsync();
|
||||||
NavigationManager.NavigateTo("/account/login");
|
NavigationManager.NavigateTo("/account/logout-form", true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
@using NexusReader.UI.Shared.Components.Atoms
|
@using NexusReader.UI.Shared.Components.Atoms
|
||||||
@inject IIdentityService IdentityService
|
@inject IIdentityService IdentityService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<div class="login-page-container">
|
<div class="login-page-container">
|
||||||
<div class="mesh-bg"></div>
|
<div class="mesh-bg"></div>
|
||||||
@@ -69,6 +70,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form id="nexusLoginForm" method="post" action="/account/login-form" style="display:none">
|
||||||
|
<input type="hidden" name="email" value="@_registerModel.Email" />
|
||||||
|
<input type="hidden" name="password" value="@_registerModel.Password" />
|
||||||
|
<input type="hidden" name="rememberMe" value="false" />
|
||||||
|
</form>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private RegisterModel _registerModel = new();
|
private RegisterModel _registerModel = new();
|
||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
@@ -87,7 +94,8 @@
|
|||||||
var loginResult = await IdentityService.LoginAsync(_registerModel.Email, _registerModel.Password);
|
var loginResult = await IdentityService.LoginAsync(_registerModel.Email, _registerModel.Password);
|
||||||
if (loginResult.IsSuccess)
|
if (loginResult.IsSuccess)
|
||||||
{
|
{
|
||||||
NavigationManager.NavigateTo("/");
|
// Trigger hidden form submission to perform cookie-based sign-in
|
||||||
|
await JS.InvokeVoidAsync("eval", "document.getElementById('nexusLoginForm').submit()");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private UserProfile? _profile;
|
private UserProfileDto? _profile;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
@page "/library"
|
@page "/library"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
@using NexusReader.UI.Shared.Components.Organisms
|
||||||
|
|
||||||
<div class="library-page">
|
<div class="library-page">
|
||||||
<header class="library-header">
|
<header class="library-header">
|
||||||
<h1>Biblioteka</h1>
|
<h1>Biblioteka</h1>
|
||||||
<AuthorizeView Roles="Admin, ContentManager">
|
<AuthorizeView Roles="Admin, ContentManager">
|
||||||
<NexusButton Class="add-book-trigger">
|
<NexusButton Class="add-book-trigger" OnClick="() => _isModalOpen = true">
|
||||||
[+] Add New Book
|
[+] Add New Book
|
||||||
</NexusButton>
|
</NexusButton>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<BookIngestionModal @bind-IsOpen="_isModalOpen" />
|
||||||
|
|
||||||
<div class="library-content glass-panel">
|
<div class="library-content glass-panel">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p>Twoja kolekcja książek i dokumentów pojawi się tutaj wkrótce.</p>
|
<p>Twoja kolekcja książek i dokumentów pojawi się tutaj wkrótce.</p>
|
||||||
@@ -51,3 +54,7 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _isModalOpen;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,5 +11,5 @@ public interface IReaderNavigationService
|
|||||||
Task GoToChapter(int index);
|
Task GoToChapter(int index);
|
||||||
Task GoToNextChapter();
|
Task GoToNextChapter();
|
||||||
Task GoToPreviousChapter();
|
Task GoToPreviousChapter();
|
||||||
void UpdateMetadata(int currentIndex, int totalChapters, string title);
|
Task UpdateMetadataAsync(int currentIndex, int totalChapters, string title);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,35 +2,11 @@ using System.Net.Http.Json;
|
|||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.Application.DTOs.User;
|
using NexusReader.Application.DTOs.User;
|
||||||
using NexusReader.UI.Shared.Constants;
|
using NexusReader.Application.Constants;
|
||||||
using FluentResults;
|
using FluentResults;
|
||||||
|
|
||||||
namespace NexusReader.UI.Shared.Services;
|
namespace NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
public interface IIdentityService
|
|
||||||
{
|
|
||||||
event Func<Task>? OnStateInvalidated;
|
|
||||||
Task<Result> RegisterAsync(string email, string password);
|
|
||||||
Task<Result> LoginAsync(string email, string password, bool rememberMe = false);
|
|
||||||
Task<Result> LogoutAsync();
|
|
||||||
Task<Result<UserProfile>> GetProfileAsync();
|
|
||||||
Task<Result> RefreshTokenAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public record UserProfile(
|
|
||||||
string Email,
|
|
||||||
int AITokensUsed,
|
|
||||||
Guid TenantId,
|
|
||||||
SubscriptionPlanDto Plan,
|
|
||||||
int AverageQuizScore,
|
|
||||||
LastReadBookDto? LastReadBook)
|
|
||||||
{
|
|
||||||
// Helper properties for UI compatibility
|
|
||||||
public string CurrentPlan => Plan?.Name ?? PlanConstants.DefaultPlanName;
|
|
||||||
public int AITokenLimit => Plan?.AITokenLimit ?? PlanConstants.DefaultTokenLimit;
|
|
||||||
public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class IdentityService : IIdentityService
|
public class IdentityService : IIdentityService
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
@@ -38,8 +14,8 @@ public class IdentityService : IIdentityService
|
|||||||
private readonly AuthenticationStateProvider? _authStateProvider;
|
private readonly AuthenticationStateProvider? _authStateProvider;
|
||||||
private const string TokenKey = StorageKeys.AuthToken;
|
private const string TokenKey = StorageKeys.AuthToken;
|
||||||
private const string RefreshTokenKey = StorageKeys.RefreshToken;
|
private const string RefreshTokenKey = StorageKeys.RefreshToken;
|
||||||
private Task<UserProfile?>? _profileTask;
|
private Task<UserProfileDto?>? _profileTask;
|
||||||
private UserProfile? _cachedProfile;
|
private UserProfileDto? _cachedProfile;
|
||||||
private DateTime _lastFetchAttempt = DateTime.MinValue;
|
private DateTime _lastFetchAttempt = DateTime.MinValue;
|
||||||
|
|
||||||
public event Func<Task>? OnStateInvalidated;
|
public event Func<Task>? OnStateInvalidated;
|
||||||
@@ -71,7 +47,7 @@ public class IdentityService : IIdentityService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await _httpClient.PostAsJsonAsync("identity/login?useCookies=true", new { email, password });
|
var response = await _httpClient.PostAsJsonAsync("identity/login", new { email, password });
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
@@ -104,11 +80,15 @@ public class IdentityService : IIdentityService
|
|||||||
var profile = profileResult.Value;
|
var profile = profileResult.Value;
|
||||||
await _storageService.SaveSecureString(StorageKeys.UserEmail, profile.Email);
|
await _storageService.SaveSecureString(StorageKeys.UserEmail, profile.Email);
|
||||||
await _storageService.SaveSecureString(StorageKeys.UserTenant, profile.TenantId.ToString());
|
await _storageService.SaveSecureString(StorageKeys.UserTenant, profile.TenantId.ToString());
|
||||||
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
|
||||||
|
var rolesStr = string.Join(",", profile.Roles ?? Array.Empty<string>());
|
||||||
|
await _storageService.SaveSecureString(StorageKeys.UserRoles, rolesStr);
|
||||||
|
|
||||||
|
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString(), rolesStr);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(email, "unknown");
|
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(email, "unknown", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
@@ -132,6 +112,7 @@ public class IdentityService : IIdentityService
|
|||||||
await _storageService.SaveSecureString(RefreshTokenKey, "");
|
await _storageService.SaveSecureString(RefreshTokenKey, "");
|
||||||
await _storageService.SaveSecureString(StorageKeys.UserEmail, "");
|
await _storageService.SaveSecureString(StorageKeys.UserEmail, "");
|
||||||
await _storageService.SaveSecureString(StorageKeys.UserTenant, "");
|
await _storageService.SaveSecureString(StorageKeys.UserTenant, "");
|
||||||
|
await _storageService.SaveSecureString(StorageKeys.UserRoles, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
|
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
|
||||||
@@ -146,7 +127,7 @@ public class IdentityService : IIdentityService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<UserProfile>> GetProfileAsync()
|
public async Task<Result<UserProfileDto>> GetProfileAsync()
|
||||||
{
|
{
|
||||||
if (_cachedProfile != null)
|
if (_cachedProfile != null)
|
||||||
{
|
{
|
||||||
@@ -166,7 +147,7 @@ public class IdentityService : IIdentityService
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
private async Task<UserProfile?> GetProfileInternalAsync()
|
private async Task<UserProfileDto?> GetProfileInternalAsync()
|
||||||
{
|
{
|
||||||
if (!System.OperatingSystem.IsBrowser())
|
if (!System.OperatingSystem.IsBrowser())
|
||||||
{
|
{
|
||||||
@@ -191,13 +172,17 @@ public class IdentityService : IIdentityService
|
|||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var profile = await response.Content.ReadFromJsonAsync<UserProfile>();
|
var profile = await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||||
if (profile != null)
|
if (profile != null)
|
||||||
{
|
{
|
||||||
_cachedProfile = profile;
|
_cachedProfile = profile;
|
||||||
await _storageService.SaveSecureString(StorageKeys.UserEmail, profile.Email);
|
await _storageService.SaveSecureString(StorageKeys.UserEmail, profile.Email);
|
||||||
await _storageService.SaveSecureString(StorageKeys.UserTenant, profile.TenantId.ToString());
|
await _storageService.SaveSecureString(StorageKeys.UserTenant, profile.TenantId.ToString());
|
||||||
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
|
||||||
|
var rolesStr = string.Join(",", profile.Roles ?? Array.Empty<string>());
|
||||||
|
await _storageService.SaveSecureString(StorageKeys.UserRoles, rolesStr);
|
||||||
|
|
||||||
|
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString(), rolesStr);
|
||||||
}
|
}
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
@@ -246,7 +231,11 @@ public class IdentityService : IIdentityService
|
|||||||
var profile = profileResult.Value;
|
var profile = profileResult.Value;
|
||||||
await _storageService.SaveSecureString(StorageKeys.UserEmail, profile.Email);
|
await _storageService.SaveSecureString(StorageKeys.UserEmail, profile.Email);
|
||||||
await _storageService.SaveSecureString(StorageKeys.UserTenant, profile.TenantId.ToString());
|
await _storageService.SaveSecureString(StorageKeys.UserTenant, profile.TenantId.ToString());
|
||||||
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
|
||||||
|
var rolesStr = string.Join(",", profile.Roles ?? Array.Empty<string>());
|
||||||
|
await _storageService.SaveSecureString(StorageKeys.UserRoles, rolesStr);
|
||||||
|
|
||||||
|
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString(), rolesStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
using FluentResults;
|
||||||
using NexusReader.Application.Queries.Graph;
|
using NexusReader.Application.Queries.Graph;
|
||||||
using NexusReader.Application.Queries.Quiz;
|
using NexusReader.Application.Queries.Quiz;
|
||||||
using NexusReader.UI.Shared.Services;
|
using NexusReader.UI.Shared.Services;
|
||||||
@@ -77,7 +78,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
|||||||
await _graphService.SetActiveNode(blockId);
|
await _graphService.SetActiveNode(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
|
public async Task<Result<KnowledgePacket>> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
|
||||||
{
|
{
|
||||||
await _quizService.SetHydrating(true);
|
await _quizService.SetHydrating(true);
|
||||||
LogRequestingSummary(tenantId);
|
LogRequestingSummary(tenantId);
|
||||||
@@ -93,20 +94,21 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
|||||||
|
|
||||||
await _quizService.SetQuiz(null, new QuizDto(quizQuestions));
|
await _quizService.SetQuiz(null, new QuizDto(quizQuestions));
|
||||||
await _platformService.VibrateSuccessAsync();
|
await _platformService.VibrateSuccessAsync();
|
||||||
return packet;
|
return Result.Ok(packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
LogSummaryWarning(tenantId);
|
LogSummaryWarning(tenantId);
|
||||||
|
return Result.Fail(result.Errors);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
LogSummaryError(ex, tenantId);
|
LogSummaryError(ex, tenantId);
|
||||||
|
return Result.Fail(new Error("Error requesting summary and quiz").CausedBy(ex));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await _quizService.SetHydrating(false);
|
await _quizService.SetHydrating(false);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ClearAsync()
|
public async Task ClearAsync()
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ using System.Security.Claims;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.UI.Shared.Constants;
|
using NexusReader.Application.Constants;
|
||||||
|
|
||||||
namespace NexusReader.UI.Shared.Services;
|
namespace NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||||
{
|
{
|
||||||
private readonly INativeStorageService _storageService;
|
private readonly INativeStorageService _storageService;
|
||||||
|
|
||||||
|
// SECURITY NOTE: We currently store roles in local storage to persist state across refreshes.
|
||||||
|
// In a production SaaS environment, consider using ProtectedBrowserStorage (Blazor Server)
|
||||||
|
// or encrypted storage/JWT claims validation to prevent client-side role tampering.
|
||||||
private const string TokenKey = StorageKeys.AuthToken;
|
private const string TokenKey = StorageKeys.AuthToken;
|
||||||
|
|
||||||
public NexusAuthenticationStateProvider(INativeStorageService storageService)
|
public NexusAuthenticationStateProvider(INativeStorageService storageService)
|
||||||
@@ -38,10 +42,15 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
|||||||
{
|
{
|
||||||
var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
|
var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
|
||||||
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
|
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
|
||||||
|
var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles);
|
||||||
|
|
||||||
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
|
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
|
||||||
{
|
{
|
||||||
_cachedState = CreateState(emailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "OpaqueBearer");
|
_cachedState = CreateState(
|
||||||
|
emailResult.Value,
|
||||||
|
tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown",
|
||||||
|
"OpaqueBearer",
|
||||||
|
rolesResult.IsSuccess ? rolesResult.Value! : "");
|
||||||
return _cachedState;
|
return _cachedState;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +60,12 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
|||||||
if (storedEmailResult.IsSuccess && !string.IsNullOrEmpty(storedEmailResult.Value))
|
if (storedEmailResult.IsSuccess && !string.IsNullOrEmpty(storedEmailResult.Value))
|
||||||
{
|
{
|
||||||
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
|
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
|
||||||
_cachedState = CreateState(storedEmailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "CookieAuth");
|
var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles);
|
||||||
|
_cachedState = CreateState(
|
||||||
|
storedEmailResult.Value,
|
||||||
|
tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown",
|
||||||
|
"CookieAuth",
|
||||||
|
rolesResult.IsSuccess ? rolesResult.Value! : "");
|
||||||
return _cachedState;
|
return _cachedState;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +81,7 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private AuthenticationState CreateState(string email, string tenantId, string authType)
|
private AuthenticationState CreateState(string email, string tenantId, string authType, string rolesStr = "")
|
||||||
{
|
{
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
@@ -75,13 +89,23 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
|||||||
new Claim(ClaimTypes.Email, email),
|
new Claim(ClaimTypes.Email, email),
|
||||||
new Claim("TenantId", tenantId)
|
new Claim("TenantId", tenantId)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(rolesStr))
|
||||||
|
{
|
||||||
|
var roles = rolesStr.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
foreach (var role in roles)
|
||||||
|
{
|
||||||
|
claims.Add(new Claim(ClaimTypes.Role, role.Trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(claims, authType);
|
var identity = new ClaimsIdentity(claims, authType);
|
||||||
return new AuthenticationState(new ClaimsPrincipal(identity));
|
return new AuthenticationState(new ClaimsPrincipal(identity));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void NotifyUserAuthentication(string email, string tenantId)
|
public void NotifyUserAuthentication(string email, string tenantId, string rolesStr = "")
|
||||||
{
|
{
|
||||||
_cachedState = CreateState(email, tenantId, "OpaqueBearer");
|
_cachedState = CreateState(email, tenantId, "OpaqueBearer", rolesStr);
|
||||||
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
|
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ public class ReaderNavigationService : IReaderNavigationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateMetadata(int currentIndex, int totalChapters, string title)
|
public async Task UpdateMetadataAsync(int currentIndex, int totalChapters, string title)
|
||||||
{
|
{
|
||||||
bool changed = false;
|
bool changed = false;
|
||||||
if (CurrentChapterIndex != currentIndex) { CurrentChapterIndex = currentIndex; changed = true; }
|
if (CurrentChapterIndex != currentIndex) { CurrentChapterIndex = currentIndex; changed = true; }
|
||||||
@@ -43,9 +43,7 @@ public class ReaderNavigationService : IReaderNavigationService
|
|||||||
|
|
||||||
if (changed)
|
if (changed)
|
||||||
{
|
{
|
||||||
// Note: UpdateMetadata remains void, so we trigger notification fire-and-forget here
|
await NotifyNavigationChangedAsync();
|
||||||
// but usually this is called during a render cycle where metadata is updated from a load.
|
|
||||||
_ = NotifyNavigationChangedAsync();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,45 +13,7 @@ public class WebStorageService : INativeStorageService
|
|||||||
_jsRuntime = jsRuntime;
|
_jsRuntime = jsRuntime;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result SaveString(string key, string value)
|
public async Task<Result> SaveStringAsync(string key, string value)
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
|
|
||||||
return Result.Ok();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Fail(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Result<string?> GetString(string key)
|
|
||||||
{
|
|
||||||
return Result.Fail("Use GetStringAsync or similar if available, or call from async context.");
|
|
||||||
}
|
|
||||||
|
|
||||||
public Result SaveBool(string key, bool value) => SaveString(key, value.ToString());
|
|
||||||
|
|
||||||
public Result<bool> GetBool(string key, bool defaultValue = false)
|
|
||||||
{
|
|
||||||
return Result.Ok(defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Result Remove(string key)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_jsRuntime.InvokeVoidAsync("localStorage.removeItem", key);
|
|
||||||
return Result.Ok();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result.Fail(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result> SaveSecureString(string key, string value)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -64,7 +26,7 @@ public class WebStorageService : INativeStorageService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<string?>> GetSecureString(string key)
|
public async Task<Result<string?>> GetStringAsync(string key)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -77,8 +39,38 @@ public class WebStorageService : INativeStorageService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result RemoveSecure(string key)
|
public Task<Result> SaveBoolAsync(string key, bool value) => SaveStringAsync(key, value.ToString());
|
||||||
|
|
||||||
|
public async Task<Result<bool>> GetBoolAsync(string key, bool defaultValue = false)
|
||||||
{
|
{
|
||||||
return Remove(key);
|
try
|
||||||
|
{
|
||||||
|
var value = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
|
||||||
|
if (string.IsNullOrEmpty(value)) return Result.Ok(defaultValue);
|
||||||
|
return Result.Ok(bool.TryParse(value, out var result) ? result : defaultValue);
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Result.Ok(defaultValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> RemoveAsync(string key)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", key);
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> SaveSecureString(string key, string value) => await SaveStringAsync(key, value);
|
||||||
|
|
||||||
|
public async Task<Result<string?>> GetSecureString(string key) => await GetStringAsync(key);
|
||||||
|
|
||||||
|
public Task<Result> RemoveSecureAsync(string key) => RemoveAsync(key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,3 +16,6 @@
|
|||||||
@using NexusReader.UI.Shared.Components.Organisms
|
@using NexusReader.UI.Shared.Components.Organisms
|
||||||
@using NexusReader.UI.Shared.Services
|
@using NexusReader.UI.Shared.Services
|
||||||
@using Microsoft.Extensions.Logging
|
@using Microsoft.Extensions.Logging
|
||||||
|
@using NexusReader.Application.Abstractions.Services
|
||||||
|
@using NexusReader.Application.DTOs.User
|
||||||
|
@using NexusReader.Application.Queries.Reader
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<PackageReference Include="MediatR" Version="12.1.1" />
|
<PackageReference Include="MediatR" Version="12.1.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||||
|
<PackageReference Include="VersOne.Epub" Version="3.3.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ builder.Services.AddSingleton<IDbContextFactory<AppDbContext>>(new ThrowingDbCon
|
|||||||
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new ThrowingEmbeddingGenerator());
|
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new ThrowingEmbeddingGenerator());
|
||||||
|
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddScoped<IEpubService, WasmEpubService>();
|
builder.Services.AddScoped<IEpubReader, WasmEpubReader>();
|
||||||
|
builder.Services.AddScoped<IEpubMetadataExtractor, WasmEpubMetadataExtractor>();
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
await builder.Build().RunAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ using NexusReader.Application.Queries.Reader;
|
|||||||
|
|
||||||
namespace NexusReader.Web.Client.Services;
|
namespace NexusReader.Web.Client.Services;
|
||||||
|
|
||||||
public class WasmEpubService : IEpubService
|
public class WasmEpubReader : IEpubReader
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
public WasmEpubService(HttpClient httpClient)
|
public WasmEpubReader(HttpClient httpClient)
|
||||||
{
|
{
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
}
|
}
|
||||||
@@ -35,4 +35,24 @@ public class WasmEpubService : IEpubService
|
|||||||
return Result.Fail(new Error($"Network or parsing error: {ex.Message}").CausedBy(ex));
|
return Result.Fail(new Error($"Network or parsing error: {ex.Message}").CausedBy(ex));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Metadata extraction moved to WasmEpubMetadataExtractor
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WasmEpubMetadataExtractor : IEpubMetadataExtractor
|
||||||
|
{
|
||||||
|
public async Task<Result<LocalEpubMetadata>> ExtractMetadataAsync(Stream epubStream)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var bookRef = await VersOne.Epub.EpubReader.OpenBookAsync(epubStream);
|
||||||
|
var title = bookRef.Title ?? "Unknown Title";
|
||||||
|
var author = bookRef.Author ?? "Unknown Author";
|
||||||
|
byte[]? cover = await bookRef.ReadCoverAsync();
|
||||||
|
return Result.Ok(new LocalEpubMetadata(title, author, cover));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error($"Failed to extract EPUB metadata locally: {ex.Message}").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using FluentResults;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.DTOs.User;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using NexusReader.Domain.Entities;
|
|
||||||
using NexusReader.Application.Queries.User;
|
|
||||||
using MediatR;
|
|
||||||
using NexusReader.UI.Shared.Services;
|
|
||||||
|
|
||||||
namespace NexusReader.Web.New.Services;
|
|
||||||
|
|
||||||
public class ServerIdentityService : IIdentityService
|
|
||||||
{
|
|
||||||
private readonly UserManager<NexusUser> _userManager;
|
|
||||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
|
||||||
private readonly IMediator _mediator;
|
|
||||||
|
|
||||||
public event Func<Task>? OnStateInvalidated;
|
|
||||||
|
|
||||||
public ServerIdentityService(
|
|
||||||
UserManager<NexusUser> userManager,
|
|
||||||
IHttpContextAccessor httpContextAccessor,
|
|
||||||
IMediator mediator)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_httpContextAccessor = httpContextAccessor;
|
|
||||||
_mediator = mediator;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<Result> LoginAsync(string email, string password, bool rememberMe = false)
|
|
||||||
=> throw new NotSupportedException("Use standard Identity endpoints for login on server.");
|
|
||||||
|
|
||||||
public Task<Result> LogoutAsync()
|
|
||||||
=> throw new NotSupportedException("Use standard Identity endpoints for logout on server.");
|
|
||||||
|
|
||||||
public Task<Result> RegisterAsync(string email, string password)
|
|
||||||
=> throw new NotSupportedException("Use standard Identity endpoints for registration on server.");
|
|
||||||
|
|
||||||
public Task<Result> RefreshTokenAsync() => Task.FromResult(Result.Ok());
|
|
||||||
|
|
||||||
public async Task<Result<UserProfile>> GetProfileAsync()
|
|
||||||
{
|
|
||||||
var user = _httpContextAccessor.HttpContext?.User;
|
|
||||||
if (user == null || !user.Identity?.IsAuthenticated == true) return Result.Fail("Not authenticated.");
|
|
||||||
|
|
||||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (userId == null) return Result.Fail("User ID not found.");
|
|
||||||
|
|
||||||
var result = await _mediator.Send(new GetUserProfileQuery(userId));
|
|
||||||
if (result.IsFailed) return Result.Fail(result.Errors);
|
|
||||||
|
|
||||||
var dto = result.Value;
|
|
||||||
|
|
||||||
// Map DTO to UI record
|
|
||||||
var profile = new UserProfile(
|
|
||||||
dto.Email,
|
|
||||||
dto.AITokensUsed,
|
|
||||||
dto.TenantId,
|
|
||||||
dto.Plan,
|
|
||||||
dto.AverageQuizScore,
|
|
||||||
dto.LastReadBook
|
|
||||||
);
|
|
||||||
|
|
||||||
return Result.Ok(profile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
@@ -16,6 +16,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Stripe.net" Version="51.1.0" />
|
<PackageReference Include="Stripe.net" Version="51.1.0" />
|
||||||
|
<PackageReference Include="VersOne.Epub" Version="3.3.6" />
|
||||||
<ProjectReference Include="..\NexusReader.Web.Client\NexusReader.Web.Client.csproj" />
|
<ProjectReference Include="..\NexusReader.Web.Client\NexusReader.Web.Client.csproj" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.7" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using NexusReader.Web.Components;
|
using NexusReader.Web.Components;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using NexusReader.Application;
|
using NexusReader.Application;
|
||||||
using NexusReader.Infrastructure;
|
using NexusReader.Infrastructure;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
@@ -37,7 +38,7 @@ builder.Services.AddSignalR();
|
|||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||||
builder.Services.AddScoped<INativeStorageService, NexusReader.UI.Shared.Services.WebStorageService>();
|
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
|
||||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||||
@@ -54,7 +55,7 @@ builder.Services.AddHttpClient("NexusAPI", client =>
|
|||||||
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
|
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
|
||||||
|
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<IIdentityService, NexusReader.Web.New.Services.ServerIdentityService>();
|
builder.Services.AddScoped<IIdentityService, NexusReader.Web.Services.ServerIdentityService>();
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
@@ -226,7 +227,7 @@ app.MapStaticAssets();
|
|||||||
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
||||||
|
|
||||||
// API endpoint for WASM client to fetch EPUB content
|
// API endpoint for WASM client to fetch EPUB content
|
||||||
app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService, ClaimsPrincipal user) =>
|
app.MapGet("/api/epub/{index}", async (int index, IEpubReader epubService, ClaimsPrincipal user) =>
|
||||||
{
|
{
|
||||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var result = await epubService.GetEpubContentAsync(index, userId);
|
var result = await epubService.GetEpubContentAsync(index, userId);
|
||||||
@@ -433,6 +434,34 @@ app.MapGet("/identity/callback/google", async (
|
|||||||
return Results.Redirect("/account/login?error=ProvisioningFailed");
|
return Results.Redirect("/account/login?error=ProvisioningFailed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.MapPost("/account/login-form", async (
|
||||||
|
[FromForm] string email,
|
||||||
|
[FromForm] string password,
|
||||||
|
[FromForm] bool rememberMe,
|
||||||
|
[FromForm] string? returnUrl,
|
||||||
|
SignInManager<NexusUser> signInManager,
|
||||||
|
ILogger<Program> logger) =>
|
||||||
|
{
|
||||||
|
var result = await signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: true);
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
logger.LogInformation("User logged in: {Email}", email);
|
||||||
|
return Results.Redirect(returnUrl ?? "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = result.IsLockedOut ? "LockedOut" : "InvalidCredentials";
|
||||||
|
return Results.Redirect($"/account/login?error={error}");
|
||||||
|
}).DisableAntiforgery(); // Simplified for now, in production use proper antiforgery
|
||||||
|
|
||||||
|
app.MapGet("/account/logout-form", async (
|
||||||
|
SignInManager<NexusUser> signInManager,
|
||||||
|
ILogger<Program> logger) =>
|
||||||
|
{
|
||||||
|
await signInManager.SignOutAsync();
|
||||||
|
logger.LogInformation("User logged out via form.");
|
||||||
|
return Results.Redirect("/account/login");
|
||||||
|
});
|
||||||
|
|
||||||
app.MapGet("/identity/profile", async (ClaimsPrincipal user, IMediator mediator) =>
|
app.MapGet("/identity/profile", async (ClaimsPrincipal user, IMediator mediator) =>
|
||||||
{
|
{
|
||||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace NexusReader.Web.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side implementation of INativeStorageService.
|
||||||
|
/// Note: In Blazor Server, localStorage is only accessible via JS Interop.
|
||||||
|
/// This implementation handles cases where JS Interop might not be available (e.g. during prerendering).
|
||||||
|
/// </summary>
|
||||||
|
public class NativeStorageService : INativeStorageService
|
||||||
|
{
|
||||||
|
private readonly IJSRuntime _jsRuntime;
|
||||||
|
|
||||||
|
public NativeStorageService(IJSRuntime jsRuntime)
|
||||||
|
{
|
||||||
|
_jsRuntime = jsRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> SaveStringAsync(string key, string value)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<string?>> GetStringAsync(string key)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var value = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
|
||||||
|
return Result.Ok(value);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Result> SaveBoolAsync(string key, bool value) => SaveStringAsync(key, value.ToString());
|
||||||
|
|
||||||
|
public async Task<Result<bool>> GetBoolAsync(string key, bool defaultValue = false)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var value = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
|
||||||
|
if (string.IsNullOrEmpty(value)) return Result.Ok(defaultValue);
|
||||||
|
return Result.Ok(bool.TryParse(value, out var result) ? result : defaultValue);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Result.Ok(defaultValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> RemoveAsync(string key)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", key);
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> SaveSecureString(string key, string value) => await SaveStringAsync(key, value);
|
||||||
|
|
||||||
|
public async Task<Result<string?>> GetSecureString(string key) => await GetStringAsync(key);
|
||||||
|
|
||||||
|
public Task<Result> RemoveSecureAsync(string key) => RemoveAsync(key);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using FluentResults;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusReader.Application.DTOs.User;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
using NexusReader.Domain.Entities;
|
||||||
|
using NexusReader.Application.Queries.User;
|
||||||
|
using MediatR;
|
||||||
|
using NexusReader.Application.Constants;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace NexusReader.Web.Services;
|
||||||
|
|
||||||
|
public class ServerIdentityService : IIdentityService
|
||||||
|
{
|
||||||
|
private readonly UserManager<NexusUser> _userManager;
|
||||||
|
private readonly SignInManager<NexusUser> _signInManager;
|
||||||
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
private readonly INativeStorageService _storageService;
|
||||||
|
|
||||||
|
public event Func<Task>? OnStateInvalidated;
|
||||||
|
|
||||||
|
public ServerIdentityService(
|
||||||
|
UserManager<NexusUser> userManager,
|
||||||
|
SignInManager<NexusUser> signInManager,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
IMediator mediator,
|
||||||
|
INativeStorageService storageService)
|
||||||
|
{
|
||||||
|
_userManager = userManager;
|
||||||
|
_signInManager = signInManager;
|
||||||
|
_httpContextAccessor = httpContextAccessor;
|
||||||
|
_mediator = mediator;
|
||||||
|
_storageService = storageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> LoginAsync(string email, string password, bool rememberMe = false)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await _userManager.FindByEmailAsync(email);
|
||||||
|
if (user == null) return Result.Fail("Nieprawidłowy e-mail lub hasło.");
|
||||||
|
|
||||||
|
// Check if account is locked
|
||||||
|
if (await _userManager.IsLockedOutAsync(user)) return Result.Fail("Konto zostało zablokowane.");
|
||||||
|
|
||||||
|
// Check password
|
||||||
|
var isCorrect = await _userManager.CheckPasswordAsync(user, password);
|
||||||
|
if (!isCorrect)
|
||||||
|
{
|
||||||
|
await _userManager.AccessFailedAsync(user);
|
||||||
|
return Result.Fail("Nieprawidłowy e-mail lub hasło.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset access failed count on success
|
||||||
|
await _userManager.ResetAccessFailedCountAsync(user);
|
||||||
|
|
||||||
|
// In Blazor Interactive Server, we cannot use PasswordSignInAsync directly
|
||||||
|
// because headers are read-only once the circuit is established.
|
||||||
|
// We return success here to indicate credentials are valid.
|
||||||
|
// The UI will then perform a POST redirect to /account/login-form to set cookies.
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error($"Błąd podczas weryfikacji poświadczeń: {ex.Message}").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> LogoutAsync()
|
||||||
|
{
|
||||||
|
// Logout via SignalR is also problematic for cookie clearing.
|
||||||
|
// The UI should redirect to /account/logout-form
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> RegisterAsync(string email, string password)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = new NexusUser
|
||||||
|
{
|
||||||
|
UserName = email,
|
||||||
|
Email = email,
|
||||||
|
SubscriptionPlanId = SubscriptionPlan.FreeId,
|
||||||
|
TenantId = "global"
|
||||||
|
};
|
||||||
|
var result = await _userManager.CreateAsync(user, password);
|
||||||
|
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
// Similar to Login, we return success but don't sign in here.
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Fail(result.Errors.Select(e => e.Description).FirstOrDefault() ?? "Rejestracja nie powiodła się.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error($"Błąd podczas rejestracji na serwerze: {ex.Message}").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Result> RefreshTokenAsync() => Task.FromResult(Result.Ok());
|
||||||
|
|
||||||
|
public async Task<Result<UserProfileDto>> GetProfileAsync()
|
||||||
|
{
|
||||||
|
var user = _httpContextAccessor.HttpContext?.User;
|
||||||
|
if (user == null || !user.Identity?.IsAuthenticated == true) return Result.Fail("Not authenticated.");
|
||||||
|
|
||||||
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userId == null) return Result.Fail("User ID not found.");
|
||||||
|
|
||||||
|
var result = await _mediator.Send(new GetUserProfileQuery(userId));
|
||||||
|
if (result.IsFailed) return Result.Fail(result.Errors);
|
||||||
|
|
||||||
|
return Result.Ok(result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.0" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.70" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FluentAssertions;
|
||||||
|
using NexusReader.Infrastructure.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Tests.Services;
|
||||||
|
|
||||||
|
public class EpubMetadataExtractorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ExtractMetadataAsync_WithInvalidStream_ReturnsFailure()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var extractor = new EpubMetadataExtractor();
|
||||||
|
using var stream = new MemoryStream(new byte[] { 0, 1, 2, 3 });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await extractor.ExtractMetadataAsync(stream);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsSuccess.Should().BeFalse();
|
||||||
|
result.Errors.Should().NotBeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user