feat: Architectural stabilization and login error resolution (#33)

- Refactored INativeStorageService and IReaderNavigationService to be fully asynchronous to ensure stable JS Interop in Blazor Server.
- Enforced Result pattern in IBillingService and KnowledgeCoordinator for better error propagation.
- Resolved 'Headers are read-only' error in Blazor Server by implementing a hybrid login/logout flow using Minimal API endpoints and hidden form submission.
- Eliminated all async void signatures across the codebase.
- Verified 0 build errors.
This commit is contained in:
2026-05-11 21:15:39 +02:00
parent e1f1a4b3cb
commit f0fac1afaa
20 changed files with 287 additions and 240 deletions
@@ -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,13 +29,15 @@ public class BillingService : IBillingService
_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);
if (user == null)
{
_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;
@@ -68,19 +71,27 @@ public class BillingService : IBillingService
{
_logger.LogError("Failed to update user {Email} after subscription change: {Errors}",
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);
if (user == null)
{
_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();
@@ -96,9 +107,15 @@ public class BillingService : IBillingService
{
_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;
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
{
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<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
{
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));
}
}
@@ -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);
}
+29
View File
@@ -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,48 +43,38 @@ 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.");
// 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();
// Logout via SignalR is also problematic for cookie clearing.
// The UI should redirect to /account/logout-form
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error("Logout failed.").CausedBy(ex));
}
}
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();
}