refactor: implement Result pattern for IdentityService and add CSS fallbacks

This commit is contained in:
2026-05-10 12:18:07 +02:00
parent 24f9a2685c
commit fc68ee41ab
10 changed files with 226 additions and 116 deletions
@@ -122,12 +122,24 @@
try try
{ {
var success = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password, _loginModel.RememberMe); var result = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password, _loginModel.RememberMe);
if (success) NavigationManager.NavigateTo("/"); if (result.IsSuccess)
else _errorMessage = "Nieprawidłowy e-mail lub hasło."; {
NavigationManager.NavigateTo("/");
}
else
{
_errorMessage = result.Errors.FirstOrDefault()?.Message ?? "Nieprawidłowy e-mail lub hasło.";
}
}
catch (Exception ex)
{
_errorMessage = $"Wystąpił błąd logowania: {ex.Message}.";
}
finally
{
_isSubmitting = false;
} }
catch (Exception ex) { _errorMessage = $"Wystąpił błąd logowania: {ex.Message}."; }
finally { _isSubmitting = false; }
} }
private void HandleGoogleLogin() => NavigationManager.NavigateTo("identity/login/google", forceLoad: true); private void HandleGoogleLogin() => NavigationManager.NavigateTo("identity/login/google", forceLoad: true);
@@ -110,7 +110,11 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_profile = await IdentityService.GetProfileAsync(); var result = await IdentityService.GetProfileAsync();
if (result.IsSuccess)
{
_profile = result.Value;
}
StateHasChanged(); StateHasChanged();
} }
@@ -119,15 +119,21 @@
} }
.glass-panel { .glass-panel {
background: rgba(255, 255, 255, 0.03); background: rgba(20, 20, 20, 0.8); /* Fallback */
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px; border-radius: 24px;
padding: 32px; padding: 32px;
transition: all 0.3s ease; transition: all 0.3s ease;
} }
@supports (backdrop-filter: blur(12px)) {
.glass-panel {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
}
.glass-panel:hover { .glass-panel:hover {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border-color: rgba(0, 255, 153, 0.2); border-color: rgba(0, 255, 153, 0.2);
@@ -81,17 +81,32 @@
try try
{ {
var success = await IdentityService.RegisterAsync(_registerModel.Email, _registerModel.Password); var regResult = await IdentityService.RegisterAsync(_registerModel.Email, _registerModel.Password);
if (success) if (regResult.IsSuccess)
{ {
var loginSuccess = await IdentityService.LoginAsync(_registerModel.Email, _registerModel.Password); var loginResult = await IdentityService.LoginAsync(_registerModel.Email, _registerModel.Password);
if (loginSuccess) NavigationManager.NavigateTo("/"); if (loginResult.IsSuccess)
else NavigationManager.NavigateTo("/account/login"); {
NavigationManager.NavigateTo("/");
}
else
{
NavigationManager.NavigateTo("/account/login");
}
}
else
{
_errorMessage = regResult.Errors.FirstOrDefault()?.Message ?? "Rejestracja nie powiodła się.";
} }
else { _errorMessage = "Rejestracja nie powiodła się."; }
} }
catch (Exception) { _errorMessage = "Wystąpił błąd podczas rejestracji."; } catch (Exception)
finally { _isSubmitting = false; } {
_errorMessage = "Wystąpił błąd podczas rejestracji.";
}
finally
{
_isSubmitting = false;
}
} }
public class RegisterModel public class RegisterModel
@@ -138,6 +138,10 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_profile = await IdentityService.GetProfileAsync(); var result = await IdentityService.GetProfileAsync();
if (result.IsSuccess)
{
_profile = result.Value;
}
} }
} }
@@ -136,14 +136,20 @@
} }
.glass-panel { .glass-panel {
background: rgba(255, 255, 255, 0.03); background: rgba(20, 20, 20, 0.8); /* Fallback for browsers without backdrop-filter */
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 20px; border-radius: 20px;
padding: 1.5rem; padding: 1.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
@supports (backdrop-filter: blur(10px)) {
.glass-panel {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(10px);
}
}
.glass-panel:hover { .glass-panel:hover {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border-color: rgba(0, 255, 153, 0.2); border-color: rgba(0, 255, 153, 0.2);
@@ -2,16 +2,18 @@ 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 FluentResults;
namespace NexusReader.UI.Shared.Services; namespace NexusReader.UI.Shared.Services;
public interface IIdentityService public interface IIdentityService
{ {
Task<bool> RegisterAsync(string email, string password); event Func<Task>? OnStateInvalidated;
Task<bool> LoginAsync(string email, string password, bool rememberMe = false); Task<Result> RegisterAsync(string email, string password);
Task LogoutAsync(); Task<Result> LoginAsync(string email, string password, bool rememberMe = false);
Task<UserProfile?> GetProfileAsync(); Task<Result> LogoutAsync();
Task<bool> RefreshTokenAsync(); Task<Result<UserProfile>> GetProfileAsync();
Task<Result> RefreshTokenAsync();
} }
public record UserProfile( public record UserProfile(
@@ -37,6 +39,9 @@ public class IdentityService : IIdentityService
private const string RefreshTokenKey = "nexus_refresh_token"; private const string RefreshTokenKey = "nexus_refresh_token";
private Task<UserProfile?>? _profileTask; private Task<UserProfile?>? _profileTask;
private UserProfile? _cachedProfile; private UserProfile? _cachedProfile;
private DateTime _lastFetchAttempt = DateTime.MinValue;
public event Func<Task>? OnStateInvalidated;
public IdentityService( public IdentityService(
HttpClient httpClient, HttpClient httpClient,
@@ -48,92 +53,114 @@ public class IdentityService : IIdentityService
_authStateProvider = authStateProvider; _authStateProvider = authStateProvider;
} }
public async Task<bool> RegisterAsync(string email, string password) public async Task<Result> RegisterAsync(string email, string password)
{ {
var response = await _httpClient.PostAsJsonAsync("identity/register", new { email, password }); try
return response.IsSuccessStatusCode; {
var response = await _httpClient.PostAsJsonAsync("identity/register", new { email, password });
return response.IsSuccessStatusCode ? Result.Ok() : Result.Fail("Rejestracja nie powiodła się.");
}
catch (Exception ex)
{
return Result.Fail(new Error("Błąd sieci podczas rejestracji.").CausedBy(ex));
}
} }
public async Task<bool> LoginAsync(string email, string password, bool rememberMe = false) public async Task<Result> LoginAsync(string email, string password, bool rememberMe = false)
{ {
var response = await _httpClient.PostAsJsonAsync("identity/login?useCookies=true", new { email, password }); try
if (response.IsSuccessStatusCode)
{ {
_cachedProfile = null; // Clear cache to force fresh fetch var response = await _httpClient.PostAsJsonAsync("identity/login?useCookies=true", new { email, password });
LoginResponse? result = null;
try
{
result = await response.Content.ReadFromJsonAsync<LoginResponse>();
}
catch (System.Text.Json.JsonException)
{
// Expected if useCookies=true and body is empty
}
if (result != null && !string.IsNullOrEmpty(result.AccessToken))
{
await _storageService.SaveSecureString(TokenKey, result.AccessToken);
if (!string.IsNullOrEmpty(result.RefreshToken))
{
await _storageService.SaveSecureString(RefreshTokenKey, result.RefreshToken);
}
}
// Always try to fetch profile after successful login (either via token or cookie)
var profile = await GetProfileAsync();
if (profile != null)
{
await _storageService.SaveSecureString("nexus_user_email", profile.Email);
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
return true;
}
// If we have a successful status code but can't get the profile,
// we might still be logged in via cookie.
// We should try to notify with whatever info we have.
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(email, "unknown"); _cachedProfile = null;
return true; LoginResponse? result = null;
try
{
result = await response.Content.ReadFromJsonAsync<LoginResponse>();
}
catch (System.Text.Json.JsonException) { }
if (result != null && !string.IsNullOrEmpty(result.AccessToken))
{
await _storageService.SaveSecureString(TokenKey, result.AccessToken);
if (!string.IsNullOrEmpty(result.RefreshToken))
{
await _storageService.SaveSecureString(RefreshTokenKey, result.RefreshToken);
}
}
(_authStateProvider as NexusAuthenticationStateProvider)?.ClearCache();
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
var profileResult = await GetProfileAsync();
if (profileResult.IsSuccess)
{
var profile = profileResult.Value;
await _storageService.SaveSecureString("nexus_user_email", profile.Email);
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
}
else
{
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(email, "unknown");
}
return Result.Ok();
} }
return Result.Fail("Nieprawidłowy e-mail lub hasło.");
} }
catch (Exception ex)
return false;
}
public async Task LogoutAsync()
{
_cachedProfile = null;
if (System.OperatingSystem.IsBrowser())
{ {
await _storageService.SaveSecureString(TokenKey, ""); return Result.Fail(new Error("Błąd połączenia z serwerem.").CausedBy(ex));
await _storageService.SaveSecureString(RefreshTokenKey, "");
await _storageService.SaveSecureString("nexus_user_email", "");
await _storageService.SaveSecureString("nexus_user_tenant", "");
} }
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserLogout();
} }
public async Task<UserProfile?> GetProfileAsync() public async Task<Result> LogoutAsync()
{
try
{
_cachedProfile = null;
if (System.OperatingSystem.IsBrowser())
{
await _storageService.SaveSecureString(TokenKey, "");
await _storageService.SaveSecureString(RefreshTokenKey, "");
await _storageService.SaveSecureString("nexus_user_email", "");
await _storageService.SaveSecureString("nexus_user_tenant", "");
}
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
(_authStateProvider as NexusAuthenticationStateProvider)?.ClearCache();
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserLogout();
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error("Błąd podczas wylogowywania.").CausedBy(ex));
}
}
public async Task<Result<UserProfile>> GetProfileAsync()
{ {
if (_cachedProfile != null) if (_cachedProfile != null)
{ {
return _cachedProfile; return Result.Ok(_cachedProfile);
} }
if (_profileTask != null) if (_profileTask != null)
{ {
return await _profileTask; var p = await _profileTask;
return p != null ? Result.Ok(p) : Result.Fail("Nie znaleziono profilu.");
} }
_profileTask = GetProfileInternalAsync(); _profileTask = GetProfileInternalAsync();
return await _profileTask; var result = await _profileTask;
return result != null ? Result.Ok(result) : Result.Fail("Błąd podczas pobierania profilu.");
} }
private DateTime _lastFetchAttempt = DateTime.MinValue;
private async Task<UserProfile?> GetProfileInternalAsync() private async Task<UserProfile?> GetProfileInternalAsync()
{ {
@@ -183,39 +210,50 @@ public class IdentityService : IIdentityService
} }
} }
public async Task<bool> RefreshTokenAsync() public async Task<Result> RefreshTokenAsync()
{ {
var result = await _storageService.GetSecureString(RefreshTokenKey); try
var refreshToken = result.IsSuccess ? result.Value : null;
if (string.IsNullOrEmpty(refreshToken)) return false;
var response = await _httpClient.PostAsJsonAsync("identity/refresh", new { refreshToken });
if (response.IsSuccessStatusCode)
{ {
var loginResult = await response.Content.ReadFromJsonAsync<LoginResponse>(); var result = await _storageService.GetSecureString(RefreshTokenKey);
if (loginResult != null && !string.IsNullOrEmpty(loginResult.AccessToken)) var refreshToken = result.IsSuccess ? result.Value : null;
if (string.IsNullOrEmpty(refreshToken)) return Result.Fail("Brak tokena odświeżania.");
var response = await _httpClient.PostAsJsonAsync("identity/refresh", new { refreshToken });
if (response.IsSuccessStatusCode)
{ {
await _storageService.SaveSecureString(TokenKey, loginResult.AccessToken); var loginResult = await response.Content.ReadFromJsonAsync<LoginResponse>();
if (!string.IsNullOrEmpty(loginResult.RefreshToken)) if (loginResult != null && !string.IsNullOrEmpty(loginResult.AccessToken))
{ {
await _storageService.SaveSecureString(RefreshTokenKey, loginResult.RefreshToken); await _storageService.SaveSecureString(TokenKey, loginResult.AccessToken);
} if (!string.IsNullOrEmpty(loginResult.RefreshToken))
{
await _storageService.SaveSecureString(RefreshTokenKey, loginResult.RefreshToken);
}
var profile = await GetProfileAsync(); _cachedProfile = null;
if (profile != null) (_authStateProvider as NexusAuthenticationStateProvider)?.ClearCache();
{ if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
await _storageService.SaveSecureString("nexus_user_email", profile.Email);
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
}
return true; var profileResult = await GetProfileAsync();
if (profileResult.IsSuccess)
{
var profile = profileResult.Value;
await _storageService.SaveSecureString("nexus_user_email", profile.Email);
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
}
return Result.Ok();
}
} }
return Result.Fail("Sesja wygasła.");
}
catch (Exception ex)
{
return Result.Fail(new Error("Błąd odświeżania sesji.").CausedBy(ex));
} }
return false;
} }
private class LoginResponse private class LoginResponse
@@ -15,6 +15,12 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
_storageService = storageService; _storageService = storageService;
} }
public void ClearCache()
{
_cachedState = null;
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
private AuthenticationState? _cachedState; private AuthenticationState? _cachedState;
public override async Task<AuthenticationState> GetAuthenticationStateAsync() public override async Task<AuthenticationState> GetAuthenticationStateAsync()
+16
View File
@@ -19,6 +19,22 @@
--nexus-transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1); --nexus-transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
} }
/* Global Glassmorphism with Fallback */
.glass-panel {
background: rgba(20, 20, 20, 0.85); /* Darker fallback for readability */
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 20px;
padding: 1.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@supports (backdrop-filter: blur(10px)) {
.glass-panel {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(10px);
}
}
.theme-light { .theme-light {
--nexus-bg: var(--nexus-paper); --nexus-bg: var(--nexus-paper);
@@ -1,4 +1,5 @@
using System.Security.Claims; using System.Security.Claims;
using FluentResults;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NexusReader.Application.DTOs.User; using NexusReader.Application.DTOs.User;
@@ -14,6 +15,8 @@ public class ServerIdentityService : IIdentityService
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IDbContextFactory<AppDbContext> _dbContextFactory; private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public event Func<Task>? OnStateInvalidated;
public ServerIdentityService( public ServerIdentityService(
UserManager<NexusUser> userManager, UserManager<NexusUser> userManager,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
@@ -24,24 +27,24 @@ public class ServerIdentityService : IIdentityService
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
} }
public Task<bool> LoginAsync(string email, string password, bool rememberMe = false) public Task<Result> LoginAsync(string email, string password, bool rememberMe = false)
=> throw new NotSupportedException("Use standard Identity endpoints for login on server."); => throw new NotSupportedException("Use standard Identity endpoints for login on server.");
public Task LogoutAsync() public Task<Result> LogoutAsync()
=> throw new NotSupportedException("Use standard Identity endpoints for logout on server."); => throw new NotSupportedException("Use standard Identity endpoints for logout on server.");
public Task<bool> RegisterAsync(string email, string password) public Task<Result> RegisterAsync(string email, string password)
=> throw new NotSupportedException("Use standard Identity endpoints for registration on server."); => throw new NotSupportedException("Use standard Identity endpoints for registration on server.");
public Task<bool> RefreshTokenAsync() => Task.FromResult(true); public Task<Result> RefreshTokenAsync() => Task.FromResult(Result.Ok());
public async Task<UserProfile?> GetProfileAsync() public async Task<Result<UserProfile>> GetProfileAsync()
{ {
var user = _httpContextAccessor.HttpContext?.User; var user = _httpContextAccessor.HttpContext?.User;
if (user == null || !user.Identity?.IsAuthenticated == true) return null; if (user == null || !user.Identity?.IsAuthenticated == true) return Result.Fail("Not authenticated.");
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return null; if (userId == null) return Result.Fail("User ID not found.");
using var dbContext = await _dbContextFactory.CreateDbContextAsync(); using var dbContext = await _dbContextFactory.CreateDbContextAsync();
@@ -73,6 +76,6 @@ public class ServerIdentityService : IIdentityService
)) ))
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
return profile; return profile != null ? Result.Ok(profile) : Result.Fail("Profile not found.");
} }
} }