feat(ui): Hub Navigation, Profile Dashboard and Auth Stability Fixes #31
@@ -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(
|
||||
@@ -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
|
||||
|
||||
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
mjasin
commented
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
mjasin
commented
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
mjasin
commented
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()
|
||||
{
|
||||
@@ -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
mjasin
commented
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()
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user
Do not eat exceptions