feat(ui): Hub Navigation, Profile Dashboard and Auth Stability Fixes #31

Merged
mjasin merged 11 commits from feat/hub-navigation-profile-dashboard into develop 2026-05-10 17:36:36 +00:00
10 changed files with 226 additions and 116 deletions
Showing only changes of commit fc68ee41ab - Show all commits
@@ -122,12 +122,24 @@
try
{
var success = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password, _loginModel.RememberMe);
if (success) NavigationManager.NavigateTo("/");
else _errorMessage = "Nieprawidłowy e-mail lub hasło.";
var result = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password, _loginModel.RememberMe);
if (result.IsSuccess)
{
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);
@@ -110,7 +110,11 @@
protected override async Task OnInitializedAsync()
{
_profile = await IdentityService.GetProfileAsync();
var result = await IdentityService.GetProfileAsync();
if (result.IsSuccess)
{
_profile = result.Value;
}
StateHasChanged();
}
@@ -119,15 +119,21 @@
}
.glass-panel {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
background: rgba(20, 20, 20, 0.8); /* Fallback */
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
padding: 32px;
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 {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(0, 255, 153, 0.2);
@@ -81,17 +81,32 @@
try
{
var success = await IdentityService.RegisterAsync(_registerModel.Email, _registerModel.Password);
if (success)
var regResult = await IdentityService.RegisterAsync(_registerModel.Email, _registerModel.Password);
if (regResult.IsSuccess)
{
var loginSuccess = await IdentityService.LoginAsync(_registerModel.Email, _registerModel.Password);
if (loginSuccess) NavigationManager.NavigateTo("/");
else NavigationManager.NavigateTo("/account/login");
var loginResult = await IdentityService.LoginAsync(_registerModel.Email, _registerModel.Password);
if (loginResult.IsSuccess)
{
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."; }
finally { _isSubmitting = false; }
catch (Exception)
{
_errorMessage = "Wystąpił błąd podczas rejestracji.";
}
finally
{
_isSubmitting = false;
}
}
public class RegisterModel
@@ -138,6 +138,10 @@
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 {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(10px);
background: rgba(20, 20, 20, 0.8); /* Fallback for browsers without backdrop-filter */
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);
}
}
.glass-panel:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(0, 255, 153, 0.2);
@@ -2,16 +2,18 @@ using System.Net.Http.Json;
using Microsoft.AspNetCore.Components.Authorization;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.User;
using FluentResults;
namespace NexusReader.UI.Shared.Services;
public interface IIdentityService
{
Task<bool> RegisterAsync(string email, string password);
Task<bool> LoginAsync(string email, string password, bool rememberMe = false);
Task LogoutAsync();
Task<UserProfile?> GetProfileAsync();
Task<bool> RefreshTokenAsync();
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(
1
@@ -37,6 +39,9 @@ public class IdentityService : IIdentityService
private const string RefreshTokenKey = "nexus_refresh_token";
private Task<UserProfile?>? _profileTask;
private UserProfile? _cachedProfile;
private DateTime _lastFetchAttempt = DateTime.MinValue;
public event Func<Task>? OnStateInvalidated;
public IdentityService(
HttpClient httpClient,
@@ -48,92 +53,114 @@ public class IdentityService : IIdentityService
_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 });
return response.IsSuccessStatusCode;
try
{
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 });
if (response.IsSuccessStatusCode)
try
{
_cachedProfile = null; // Clear cache to force fresh fetch
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;
}
var response = await _httpClient.PostAsJsonAsync("identity/login?useCookies=true", new { email, password });
// 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)
{
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(email, "unknown");
return true;
}
}
_cachedProfile = null;
LoginResponse? result = null;
try
{
result = await response.Content.ReadFromJsonAsync<LoginResponse>();
}
catch (System.Text.Json.JsonException) { }
mjasin marked this conversation as resolved
Review

Do not eat exceptions

Do not *eat exceptions*
return false;
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 Result.Fail(new Error("Błąd połączenia z serwerem.").CausedBy(ex));
}
}
public async Task LogoutAsync()
public async Task<Result> LogoutAsync()
{
_cachedProfile = null;
if (System.OperatingSystem.IsBrowser())
try
{
await _storageService.SaveSecureString(TokenKey, "");
await _storageService.SaveSecureString(RefreshTokenKey, "");
await _storageService.SaveSecureString("nexus_user_email", "");
await _storageService.SaveSecureString("nexus_user_tenant", "");
_cachedProfile = null;
if (System.OperatingSystem.IsBrowser())
{
await _storageService.SaveSecureString(TokenKey, "");
await _storageService.SaveSecureString(RefreshTokenKey, "");
await _storageService.SaveSecureString("nexus_user_email", "");
mjasin marked this conversation as resolved
Review

Use const variables instead of magic strings

Use const variables instead of *magic strings*
await _storageService.SaveSecureString("nexus_user_tenant", "");
mjasin marked this conversation as resolved
Review

Use const variables instead of magic strings

Use const variables instead of *magic strings*
}
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));
}
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserLogout();
}
public async Task<UserProfile?> GetProfileAsync()
public async Task<Result<UserProfile>> GetProfileAsync()
{
if (_cachedProfile != null)
{
return _cachedProfile;
return Result.Ok(_cachedProfile);
}
if (_profileTask != null)
{
return await _profileTask;
mjasin marked this conversation as resolved
Review

Use const variables instead of magic strings

Use const variables instead of *magic strings*
var p = await _profileTask;
return p != null ? Result.Ok(p) : Result.Fail("Nie znaleziono profilu.");
}
_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()
{
1
@@ -183,39 +210,50 @@ public class IdentityService : IIdentityService
}
}
public async Task<bool> RefreshTokenAsync()
public async Task<Result> RefreshTokenAsync()
{
var result = await _storageService.GetSecureString(RefreshTokenKey);
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)
try
{
var loginResult = await response.Content.ReadFromJsonAsync<LoginResponse>();
if (loginResult != null && !string.IsNullOrEmpty(loginResult.AccessToken))
var result = await _storageService.GetSecureString(RefreshTokenKey);
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);
if (!string.IsNullOrEmpty(loginResult.RefreshToken))
var loginResult = await response.Content.ReadFromJsonAsync<LoginResponse>();
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();
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());
}
_cachedProfile = null;
(_authStateProvider as NexusAuthenticationStateProvider)?.ClearCache();
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
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());
mjasin marked this conversation as resolved
Review

Use const variables instead of magic strings

Use const variables instead of *magic strings*
(_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
@@ -15,6 +15,12 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
_storageService = storageService;
}
public void ClearCache()
{
_cachedState = null;
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
private AuthenticationState? _cachedState;
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
1
+16
View File
@@ -19,6 +19,22 @@
--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 {
--nexus-bg: var(--nexus-paper);
@@ -1,4 +1,5 @@
using System.Security.Claims;
using FluentResults;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.DTOs.User;
@@ -14,6 +15,8 @@ public class ServerIdentityService : IIdentityService
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public event Func<Task>? OnStateInvalidated;
public ServerIdentityService(
UserManager<NexusUser> userManager,
IHttpContextAccessor httpContextAccessor,
@@ -24,24 +27,24 @@ public class ServerIdentityService : IIdentityService
_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.");
public Task LogoutAsync()
public Task<Result> LogoutAsync()
=> 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.");
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;
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);
if (userId == null) return null;
if (userId == null) return Result.Fail("User ID not found.");
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
@@ -73,6 +76,6 @@ public class ServerIdentityService : IIdentityService
))
.FirstOrDefaultAsync();
return profile;
return profile != null ? Result.Ok(profile) : Result.Fail("Profile not found.");
}
}