Refactor: Web Consolidation and Identity Stabilization #40
@@ -1,9 +1,10 @@
|
||||
using NexusReader.Domain.Entities;
|
||||
using FluentResults;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
public interface IBillingService
|
||||
{
|
||||
Task<bool> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId);
|
||||
Task<bool> HandleSubscriptionDeletedAsync(string customerEmail);
|
||||
Task<Result> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId);
|
||||
Task<Result> HandleSubscriptionDeletedAsync(string customerEmail);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
public interface INativeStorageService
|
||||
{
|
||||
Result SaveString(string key, string value);
|
||||
Result<string?> GetString(string key);
|
||||
Result SaveBool(string key, bool value);
|
||||
Result<bool> GetBool(string key, bool defaultValue = false);
|
||||
Result Remove(string key);
|
||||
Task<Result> SaveStringAsync(string key, string value);
|
||||
Task<Result<string?>> GetStringAsync(string key);
|
||||
Task<Result> SaveBoolAsync(string key, bool value);
|
||||
Task<Result<bool>> GetBoolAsync(string key, bool defaultValue = false);
|
||||
Task<Result> RemoveAsync(string key);
|
||||
|
||||
Task<Result> SaveSecureString(string key, string value);
|
||||
Task<Result<string?>> GetSecureString(string key);
|
||||
Result RemoveSecure(string key);
|
||||
Task<Result> RemoveSecureAsync(string key);
|
||||
}
|
||||
|
||||
@@ -6,66 +6,66 @@ namespace NexusReader.Infrastructure.Mobile.Services;
|
||||
|
||||
public sealed class MauiStorageService : INativeStorageService
|
||||
{
|
||||
public Result SaveString(string key, string value)
|
||||
public Task<Result> SaveStringAsync(string key, string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
Preferences.Default.Set(key, value);
|
||||
return Result.Ok();
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
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
|
||||
{
|
||||
return Result.Ok(Preferences.Default.Get(key, (string?)null));
|
||||
return Task.FromResult(Result.Ok(Preferences.Default.Get(key, (string?)null)));
|
||||
}
|
||||
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
|
||||
{
|
||||
Preferences.Default.Set(key, value);
|
||||
return Result.Ok();
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
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
|
||||
{
|
||||
return Result.Ok(Preferences.Default.Get(key, defaultValue));
|
||||
return Task.FromResult(Result.Ok(Preferences.Default.Get(key, defaultValue)));
|
||||
}
|
||||
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
|
||||
{
|
||||
Preferences.Default.Remove(key);
|
||||
return Result.Ok();
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
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
|
||||
{
|
||||
SecureStorage.Default.Remove(key);
|
||||
return Result.Ok();
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
return Task.FromResult(Result.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Infrastructure.Configuration;
|
||||
using NexusReader.Data.Persistence;
|
||||
using FluentResults;
|
||||
|
||||
namespace NexusReader.Infrastructure.Services;
|
||||
|
||||
@@ -28,77 +29,93 @@ public class BillingService : IBillingService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId)
|
||||
public async Task<Result> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(customerEmail);
|
||||
if (user == null)
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Attempted to update subscription for non-existent user: {Email}", customerEmail);
|
||||
return false;
|
||||
}
|
||||
var user = await _userManager.FindByEmailAsync(customerEmail);
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning("Attempted to update subscription for non-existent user: {Email}", customerEmail);
|
||||
return Result.Fail($"User {customerEmail} not found.");
|
||||
}
|
||||
|
||||
string targetPlanName = SubscriptionPlan.FreeName;
|
||||
int tokenLimit = 1000;
|
||||
string targetPlanName = SubscriptionPlan.FreeName;
|
||||
int tokenLimit = 1000;
|
||||
|
||||
if (stripeProductId == _stripeSettings.ProProductId)
|
||||
{
|
||||
targetPlanName = SubscriptionPlan.ProName;
|
||||
tokenLimit = 50000;
|
||||
}
|
||||
else if (stripeProductId == _stripeSettings.BasicProductId)
|
||||
{
|
||||
targetPlanName = SubscriptionPlan.BasicName;
|
||||
tokenLimit = 10000;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(stripeProductId) && stripeProductId != _stripeSettings.FreeProductId)
|
||||
{
|
||||
_logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail);
|
||||
}
|
||||
if (stripeProductId == _stripeSettings.ProProductId)
|
||||
{
|
||||
targetPlanName = SubscriptionPlan.ProName;
|
||||
tokenLimit = 50000;
|
||||
}
|
||||
else if (stripeProductId == _stripeSettings.BasicProductId)
|
||||
{
|
||||
targetPlanName = SubscriptionPlan.BasicName;
|
||||
tokenLimit = 10000;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(stripeProductId) && stripeProductId != _stripeSettings.FreeProductId)
|
||||
{
|
||||
_logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail);
|
||||
}
|
||||
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName);
|
||||
if (plan != null)
|
||||
{
|
||||
user.SubscriptionPlanId = plan.Id;
|
||||
user.AITokenLimit = tokenLimit;
|
||||
}
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == targetPlanName);
|
||||
if (plan != null)
|
||||
{
|
||||
user.SubscriptionPlanId = plan.Id;
|
||||
user.AITokenLimit = tokenLimit;
|
||||
}
|
||||
|
||||
var result = await _userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
_logger.LogError("Failed to update user {Email} after subscription change: {Errors}",
|
||||
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
return false;
|
||||
}
|
||||
var result = await _userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
_logger.LogError("Failed to update user {Email} after subscription change: {Errors}",
|
||||
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
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)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(customerEmail);
|
||||
if (user == null)
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Attempted to delete subscription for non-existent user: {Email}", customerEmail);
|
||||
return false;
|
||||
}
|
||||
var user = await _userManager.FindByEmailAsync(customerEmail);
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning("Attempted to delete subscription for non-existent user: {Email}", customerEmail);
|
||||
return Result.Fail($"User {customerEmail} not found.");
|
||||
}
|
||||
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var freePlan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName);
|
||||
if (freePlan != null)
|
||||
{
|
||||
user.SubscriptionPlanId = freePlan.Id;
|
||||
user.AITokenLimit = freePlan.AITokenLimit;
|
||||
}
|
||||
|
||||
var result = await _userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
_logger.LogError("Failed to reset user {Email} to Free tier after subscription deletion: {Errors}",
|
||||
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
return false;
|
||||
}
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
var freePlan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == SubscriptionPlan.FreeName);
|
||||
if (freePlan != null)
|
||||
{
|
||||
user.SubscriptionPlanId = freePlan.Id;
|
||||
user.AITokenLimit = freePlan.AITokenLimit;
|
||||
}
|
||||
|
||||
var result = await _userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
_logger.LogError("Failed to reset user {Email} to Free tier after subscription deletion: {Errors}",
|
||||
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,68 +4,68 @@ using NexusReader.Application.Abstractions.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);
|
||||
return Result.Ok();
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
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)
|
||||
{
|
||||
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);
|
||||
return Result.Ok();
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
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)
|
||||
{
|
||||
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);
|
||||
return Result.Ok();
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
return Task.FromResult(Result.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +86,7 @@ public class MauiStorageService : INativeStorageService
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = await SecureStorage.Default.GetAsync(key);
|
||||
return Result.Ok(value);
|
||||
return Result.Ok(await SecureStorage.Default.GetAsync(key));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -95,16 +94,16 @@ public class MauiStorageService : INativeStorageService
|
||||
}
|
||||
}
|
||||
|
||||
public Result RemoveSecure(string key)
|
||||
public Task<Result> RemoveSecureAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureStorage.Default.Remove(key);
|
||||
return Result.Ok();
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
return Task.FromResult(Result.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@
|
||||
? FullPageContent
|
||||
: $"[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;
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
private async Task HandleLogout()
|
||||
{
|
||||
await IdentityService.LogoutAsync();
|
||||
NavigationManager.NavigateTo("/", true);
|
||||
NavigationManager.NavigateTo("/account/logout-form", true);
|
||||
}
|
||||
|
||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||
|
||||
@@ -80,7 +80,8 @@
|
||||
? $"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;
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
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
|
||||
await Coordinator.ProcessFullPageAsync(GetFullPageContent());
|
||||
|
||||
@@ -101,6 +101,6 @@
|
||||
private async Task HandleLogout()
|
||||
{
|
||||
await IdentityService.LogoutAsync();
|
||||
NavigationManager.NavigateTo("/", true);
|
||||
NavigationManager.NavigateTo("/account/logout-form", true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@inject IIdentityService IdentityService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div class="login-page-container">
|
||||
<div class="mesh-bg"></div>
|
||||
@@ -90,6 +91,12 @@
|
||||
</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 {
|
||||
[Parameter]
|
||||
[SupplyParameterFromQuery(Name = "error")]
|
||||
@@ -125,7 +132,8 @@
|
||||
var result = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password, _loginModel.RememberMe);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
NavigationManager.NavigateTo("/", forceLoad: true);
|
||||
// Trigger hidden form submission to perform cookie-based sign-in
|
||||
await JS.InvokeVoidAsync("eval", "document.getElementById('nexusLoginForm').submit()");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -133,6 +133,6 @@
|
||||
private async Task HandleLogout()
|
||||
{
|
||||
await IdentityService.LogoutAsync();
|
||||
NavigationManager.NavigateTo("/account/login");
|
||||
NavigationManager.NavigateTo("/account/logout-form", true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@inject IIdentityService IdentityService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div class="login-page-container">
|
||||
<div class="mesh-bg"></div>
|
||||
@@ -69,6 +70,12 @@
|
||||
</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 {
|
||||
private RegisterModel _registerModel = new();
|
||||
private string? _errorMessage;
|
||||
@@ -87,7 +94,8 @@
|
||||
var loginResult = await IdentityService.LoginAsync(_registerModel.Email, _registerModel.Password);
|
||||
if (loginResult.IsSuccess)
|
||||
{
|
||||
NavigationManager.NavigateTo("/");
|
||||
// Trigger hidden form submission to perform cookie-based sign-in
|
||||
await JS.InvokeVoidAsync("eval", "document.getElementById('nexusLoginForm').submit()");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -11,5 +11,5 @@ public interface IReaderNavigationService
|
||||
Task GoToChapter(int index);
|
||||
Task GoToNextChapter();
|
||||
Task GoToPreviousChapter();
|
||||
void UpdateMetadata(int currentIndex, int totalChapters, string title);
|
||||
Task UpdateMetadataAsync(int currentIndex, int totalChapters, string title);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Queries.Graph;
|
||||
using NexusReader.Application.Queries.Quiz;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
@@ -77,7 +78,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
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);
|
||||
LogRequestingSummary(tenantId);
|
||||
@@ -93,20 +94,21 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
|
||||
await _quizService.SetQuiz(null, new QuizDto(quizQuestions));
|
||||
await _platformService.VibrateSuccessAsync();
|
||||
return packet;
|
||||
return Result.Ok(packet);
|
||||
}
|
||||
|
||||
LogSummaryWarning(tenantId);
|
||||
return Result.Fail(result.Errors);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogSummaryError(ex, tenantId);
|
||||
return Result.Fail(new Error("Error requesting summary and quiz").CausedBy(ex));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _quizService.SetHydrating(false);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task ClearAsync()
|
||||
|
||||
@@ -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;
|
||||
if (CurrentChapterIndex != currentIndex) { CurrentChapterIndex = currentIndex; changed = true; }
|
||||
@@ -43,9 +43,7 @@ public class ReaderNavigationService : IReaderNavigationService
|
||||
|
||||
if (changed)
|
||||
{
|
||||
// Note: UpdateMetadata remains void, so we trigger notification fire-and-forget here
|
||||
// but usually this is called during a render cycle where metadata is updated from a load.
|
||||
_ = NotifyNavigationChangedAsync();
|
||||
await NotifyNavigationChangedAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,45 +13,7 @@ public class WebStorageService : INativeStorageService
|
||||
_jsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
public Result SaveString(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)
|
||||
public async Task<Result> SaveStringAsync(string key, string value)
|
||||
{
|
||||
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
|
||||
{
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using NexusReader.Web.Components;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NexusReader.Application;
|
||||
using NexusReader.Infrastructure;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
@@ -433,6 +434,34 @@ app.MapGet("/identity/callback/google", async (
|
||||
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) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
||||
@@ -11,46 +11,14 @@ namespace NexusReader.Web.Services;
|
||||
/// </summary>
|
||||
public class NativeStorageService : INativeStorageService
|
||||
{
|
||||
private readonly Microsoft.JSInterop.IJSRuntime _jsRuntime;
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
|
||||
public NativeStorageService(Microsoft.JSInterop.IJSRuntime jsRuntime)
|
||||
public NativeStorageService(IJSRuntime jsRuntime)
|
||||
{
|
||||
_jsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
public Result SaveString(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) => Result.Fail("Async retrieval required for server-side storage.");
|
||||
|
||||
public Result SaveBool(string key, bool value) => SaveString(key, value.ToString());
|
||||
|
||||
public Result<bool> GetBool(string key, bool defaultValue = false) => 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)
|
||||
public async Task<Result> SaveStringAsync(string key, string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -63,7 +31,7 @@ public class NativeStorageService : INativeStorageService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<string?>> GetSecureString(string key)
|
||||
public async Task<Result<string?>> GetStringAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -76,5 +44,38 @@ public class NativeStorageService : INativeStorageService
|
||||
}
|
||||
}
|
||||
|
||||
public Result RemoveSecure(string key) => Remove(key);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -43,47 +43,37 @@ public class ServerIdentityService : IIdentityService
|
||||
var user = await _userManager.FindByEmailAsync(email);
|
||||
if (user == null) return Result.Fail("Nieprawidłowy e-mail lub hasło.");
|
||||
|
||||
var result = await _signInManager.PasswordSignInAsync(user, password, rememberMe, lockoutOnFailure: true);
|
||||
|
||||
if (result.Succeeded) return Result.Ok();
|
||||
if (result.IsLockedOut) return Result.Fail("Konto zostało zablokowane.");
|
||||
if (result.RequiresTwoFactor) return Result.Fail("Wymagana weryfikacja dwuetapowa.");
|
||||
|
||||
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 logowania na serwerze: {ex.Message}").CausedBy(ex));
|
||||
return Result.Fail(new Error($"Błąd podczas weryfikacji poświadczeń: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> LogoutAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _signInManager.SignOutAsync();
|
||||
|
||||
// Clear storage if available (Interactive Server mode)
|
||||
try
|
||||
{
|
||||
await _storageService.SaveSecureString(StorageKeys.AuthToken, "");
|
||||
await _storageService.SaveSecureString(StorageKeys.RefreshToken, "");
|
||||
await _storageService.SaveSecureString(StorageKeys.UserEmail, "");
|
||||
await _storageService.SaveSecureString(StorageKeys.UserTenant, "");
|
||||
await _storageService.SaveSecureString(StorageKeys.UserRoles, "");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during prerendering where JS interop isn't available
|
||||
}
|
||||
|
||||
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Logout failed.").CausedBy(ex));
|
||||
}
|
||||
// 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)
|
||||
@@ -101,7 +91,7 @@ public class ServerIdentityService : IIdentityService
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await _signInManager.SignInAsync(user, isPersistent: false);
|
||||
// Similar to Login, we return success but don't sign in here.
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user