feat: integrate d3-org-chart for network visualization and add SignalR token notification service

This commit is contained in:
masoodafar-web
2025-12-18 03:19:06 +03:30
parent 27c2c0259b
commit 6b457d0ce6
21 changed files with 1558 additions and 201 deletions
+57 -1
View File
@@ -1,4 +1,5 @@
using Blazored.LocalStorage;
using FrontOffice.BFF.User.Protobuf.Protos.User;
using Microsoft.AspNetCore.Components;
using MudBlazor;
using System.IdentityModel.Tokens.Jwt;
@@ -11,15 +12,25 @@ public class AuthService
private readonly NavigationManager _navigation;
private readonly ISnackbar _snackbar;
private readonly UserAuthInfo _userAuthInfo;
private readonly UserContract.UserContractClient _userContract;
private readonly ILogger<AuthService> _logger;
private const string TokenStorageKey = "auth:token";
public AuthService(ILocalStorageService localStorage, NavigationManager navigation, ISnackbar snackbar, UserAuthInfo userAuthInfo)
public AuthService(
ILocalStorageService localStorage,
NavigationManager navigation,
ISnackbar snackbar,
UserAuthInfo userAuthInfo,
UserContract.UserContractClient userContract,
ILogger<AuthService> logger)
{
_localStorage = localStorage;
_navigation = navigation;
_snackbar = snackbar;
_userAuthInfo = userAuthInfo;
_userContract = userContract;
_logger = logger;
}
public async Task<bool> IsAuthenticatedAsync()
@@ -99,6 +110,51 @@ public class AuthService
_navigation.NavigateTo(RouteConstants.Main.MainPage);
}
/// <summary>
/// Refresh the user's token by calling the BFF RefreshToken API.
/// If successful, the new token is stored and user info is updated.
/// </summary>
/// <returns>True if token was refreshed successfully</returns>
public async Task<bool> RefreshTokenAsync()
{
try
{
var currentToken = await GetTokenAsync();
if (string.IsNullOrEmpty(currentToken))
{
_logger.LogWarning("No token found for refresh");
return false;
}
var response = await _userContract.RefreshTokenAsync(new RefreshTokenRequest
{
CurrentToken = currentToken
});
if (response.Success && !string.IsNullOrEmpty(response.Token))
{
// Store the new token
await _localStorage.SetItemAsync(TokenStorageKey, response.Token);
// Update user auth info with new token claims
await InitUserAuthInfo();
_logger.LogInformation("Token refreshed successfully");
return true;
}
else
{
_logger.LogInformation("Token refresh not needed: {Message}", response.Message);
return false;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh token");
return false;
}
}
public async Task RequireAuthenticationAsync()
{
var isAuthenticated = await IsAuthenticatedAsync();
@@ -9,10 +9,32 @@ public class NetworkNodeDto
public string FullName { get; set; } = string.Empty;
public string Mobile { get; set; } = string.Empty;
public string? Avatar { get; set; }
public string Position { get; set; } = string.Empty; // "Left" or "Right"
public string Position { get; set; } = string.Empty; // "Root", "Left" or "Right"
public NetworkNodeDto? LeftChild { get; set; }
public NetworkNodeDto? RightChild { get; set; }
public int Level { get; set; }
public bool IsActive { get; set; } = true;
public DateTime? JoinedAt { get; set; }
public bool IsClubActive { get; set; }
public string? ActivationWeekNumber { get; set; }
}
/// <summary>
/// DTO for flat node structure (for d3-org-chart)
/// </summary>
public class FlatNetworkNodeDto
{
public string Id { get; set; } = string.Empty;
public string ParentId { get; set; } = string.Empty;
public string FullName { get; set; } = string.Empty;
public string Mobile { get; set; } = string.Empty;
public string? Avatar { get; set; }
public string Position { get; set; } = string.Empty;
public int Level { get; set; }
public bool IsActive { get; set; } = true;
public bool IsClubActive { get; set; }
public string? ActivationWeekNumber { get; set; }
public DateTime? JoinedAt { get; set; }
}
/// <summary>
@@ -23,6 +45,48 @@ public class NetworkTreeDto
public NetworkNodeDto? RootNode { get; set; }
public int TotalMembers { get; set; }
public int CurrentDepth { get; set; }
/// <summary>
/// Convert hierarchical tree to flat array for d3-org-chart
/// </summary>
public List<FlatNetworkNodeDto> ToFlatArray()
{
var result = new List<FlatNetworkNodeDto>();
if (RootNode == null) return result;
TraverseAndFlatten(RootNode, "", result);
return result;
}
private void TraverseAndFlatten(NetworkNodeDto node, string parentId, List<FlatNetworkNodeDto> result)
{
var flatNode = new FlatNetworkNodeDto
{
Id = node.UserId.ToString(),
ParentId = parentId,
FullName = node.FullName,
Mobile = node.Mobile,
Avatar = node.Avatar,
Position = node.Position,
Level = node.Level,
IsActive = node.IsActive,
IsClubActive = node.IsClubActive,
ActivationWeekNumber = node.ActivationWeekNumber,
JoinedAt = node.JoinedAt
};
result.Add(flatNode);
if (node.LeftChild != null)
{
TraverseAndFlatten(node.LeftChild, flatNode.Id, result);
}
if (node.RightChild != null)
{
TraverseAndFlatten(node.RightChild, flatNode.Id, result);
}
}
}
/// <summary>
@@ -41,6 +41,39 @@ public class NetworkMembershipService
}
}
/// <summary>
/// Get subordinate's network tree (with security check on backend)
/// Maps to: NetworkMembershipCQ.GetSubordinateTree
/// </summary>
public async Task<NetworkTreeDto> GetSubordinateTreeAsync(long targetUserId, int maxDepth = 3)
{
try
{
var request = new GetSubordinateTreeRequest
{
TargetUserId = targetUserId,
MaxDepth = maxDepth
};
var response = await _client.GetSubordinateTreeAsync(request);
return new NetworkTreeDto
{
RootNode = MapNodeFromProto(response.RootNode),
TotalMembers = response.TotalMembers,
CurrentDepth = response.CurrentDepth
};
}
catch (Grpc.Core.RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.PermissionDenied)
{
throw new UnauthorizedAccessException("شما اجازه مشاهده درخت این کاربر را ندارید");
}
catch
{
// Fallback to current user's tree
return await GetMyNetworkTreeAsync(maxDepth);
}
}
/// <summary>
/// Get current user's network statistics
/// Maps to: NetworkMembershipCQ.GetMyNetworkStatistics
@@ -102,6 +135,10 @@ public class NetworkMembershipService
Avatar = node.Avatar,
Position = node.Position,
Level = node.Level,
IsActive = node.HasChildren, // Temporary: use HasChildren as active indicator until proto is updated
IsClubActive = false, // Will be populated when proto is updated
ActivationWeekNumber = null, // Will be populated when proto is updated
JoinedAt = null, // Will be populated when proto is updated
LeftChild = MapNodeFromProto(node.LeftChild),
RightChild = MapNodeFromProto(node.RightChild)
};
@@ -111,8 +148,8 @@ public class NetworkMembershipService
{
return new NetworkTreeDto
{
CurrentDepth = 2,
TotalMembers = 5,
CurrentDepth = 3,
TotalMembers = 7,
RootNode = new NetworkNodeDto
{
UserId = 1,
@@ -120,13 +157,38 @@ public class NetworkMembershipService
Mobile = "09121234567",
Position = "Root",
Level = 0,
IsActive = true,
IsClubActive = true,
LeftChild = new NetworkNodeDto
{
UserId = 2,
FullName = "علی محمدی",
Mobile = "09121234568",
Position = "Left",
Level = 1
Level = 1,
IsActive = true,
IsClubActive = true,
JoinedAt = DateTime.Now.AddDays(-30),
LeftChild = new NetworkNodeDto
{
UserId = 4,
FullName = "رضا کریمی",
Mobile = "09121234570",
Position = "Left",
Level = 2,
IsActive = true,
JoinedAt = DateTime.Now.AddDays(-15)
},
RightChild = new NetworkNodeDto
{
UserId = 5,
FullName = "زهرا احمدی",
Mobile = "09121234571",
Position = "Right",
Level = 2,
IsActive = false,
JoinedAt = DateTime.Now.AddDays(-10)
}
},
RightChild = new NetworkNodeDto
{
@@ -134,7 +196,20 @@ public class NetworkMembershipService
FullName = "فاطمه حسینی",
Mobile = "09121234569",
Position = "Right",
Level = 1
Level = 1,
IsActive = true,
JoinedAt = DateTime.Now.AddDays(-25),
LeftChild = new NetworkNodeDto
{
UserId = 6,
FullName = "محمد نوری",
Mobile = "09121234572",
Position = "Left",
Level = 2,
IsActive = true,
IsClubActive = true,
JoinedAt = DateTime.Now.AddDays(-5)
}
}
}
};
@@ -0,0 +1,216 @@
using Microsoft.AspNetCore.SignalR.Client;
using Blazored.LocalStorage;
namespace FrontOffice.Main.Utilities;
/// <summary>
/// Service for managing SignalR connection and handling token-related notifications from BFF.
/// This service connects to the BFF SignalR Hub and notifies the application when token refresh is needed.
/// </summary>
public class TokenNotificationService : IAsyncDisposable
{
private readonly ILocalStorageService _localStorage;
private readonly IConfiguration _configuration;
private readonly ILogger<TokenNotificationService> _logger;
private HubConnection? _hubConnection;
/// <summary>
/// Event fired when a token revoked notification is received from server
/// </summary>
public event Func<TokenRevokedEventArgs, Task>? OnTokenRevoked;
/// <summary>
/// Event fired when a force refresh token notification is received from server
/// </summary>
public event Func<ForceRefreshEventArgs, Task>? OnForceRefreshToken;
/// <summary>
/// Event fired when a broadcast message is received from server
/// </summary>
public event Func<BroadcastMessageEventArgs, Task>? OnBroadcastMessage;
/// <summary>
/// Event fired when connection state changes
/// </summary>
public event Action<HubConnectionState>? OnConnectionStateChanged;
public TokenNotificationService(
ILocalStorageService localStorage,
IConfiguration configuration,
ILogger<TokenNotificationService> logger)
{
_localStorage = localStorage;
_configuration = configuration;
_logger = logger;
}
/// <summary>
/// Gets the current connection state
/// </summary>
public HubConnectionState ConnectionState => _hubConnection?.State ?? HubConnectionState.Disconnected;
/// <summary>
/// Connect to the SignalR Hub with the user's authentication token
/// </summary>
public async Task ConnectAsync()
{
if (_hubConnection?.State == HubConnectionState.Connected)
{
_logger.LogWarning("Already connected to SignalR Hub");
return;
}
try
{
var token = await _localStorage.GetItemAsync<string>("authToken");
if (string.IsNullOrEmpty(token))
{
_logger.LogWarning("No auth token found, cannot connect to SignalR Hub");
return;
}
var gwUrl = _configuration["GwUrl"]?.TrimEnd('/') ?? "https://localhost:5002";
var hubPath = _configuration["SignalR:HubPath"] ?? "/hubs/token-relay";
var hubUrl = $"{gwUrl}{hubPath}";
_logger.LogInformation("Connecting to SignalR Hub at {HubUrl}", hubUrl);
_hubConnection = new HubConnectionBuilder()
.WithUrl(hubUrl, options =>
{
options.AccessTokenProvider = () => Task.FromResult<string?>(token);
})
.WithAutomaticReconnect(new[]
{
TimeSpan.FromSeconds(0),
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30)
})
.Build();
RegisterEventHandlers();
await _hubConnection.StartAsync();
_logger.LogInformation("Successfully connected to SignalR Hub");
OnConnectionStateChanged?.Invoke(HubConnectionState.Connected);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to connect to SignalR Hub");
OnConnectionStateChanged?.Invoke(HubConnectionState.Disconnected);
}
}
/// <summary>
/// Disconnect from the SignalR Hub
/// </summary>
public async Task DisconnectAsync()
{
if (_hubConnection != null)
{
await _hubConnection.StopAsync();
_logger.LogInformation("Disconnected from SignalR Hub");
OnConnectionStateChanged?.Invoke(HubConnectionState.Disconnected);
}
}
private void RegisterEventHandlers()
{
if (_hubConnection == null) return;
// Handle TokenRevoked event
_hubConnection.On<TokenRevokedEventArgs>("TokenRevoked", async args =>
{
_logger.LogInformation("Received TokenRevoked notification. UserId: {UserId}, Reason: {Reason}",
args.UserId, args.Reason);
if (OnTokenRevoked != null)
{
await OnTokenRevoked.Invoke(args);
}
});
// Handle ForceRefreshToken event
_hubConnection.On<ForceRefreshEventArgs>("ForceRefreshToken", async args =>
{
_logger.LogInformation("Received ForceRefreshToken notification. UserId: {UserId}", args.UserId);
if (OnForceRefreshToken != null)
{
await OnForceRefreshToken.Invoke(args);
}
});
// Handle BroadcastMessage event
_hubConnection.On<BroadcastMessageEventArgs>("BroadcastMessage", async args =>
{
_logger.LogInformation("Received BroadcastMessage: {Message}", args.Message);
if (OnBroadcastMessage != null)
{
await OnBroadcastMessage.Invoke(args);
}
});
// Handle connection state changes
_hubConnection.Reconnecting += error =>
{
_logger.LogWarning(error, "SignalR connection lost. Attempting to reconnect...");
OnConnectionStateChanged?.Invoke(HubConnectionState.Reconnecting);
return Task.CompletedTask;
};
_hubConnection.Reconnected += connectionId =>
{
_logger.LogInformation("SignalR reconnected. ConnectionId: {ConnectionId}", connectionId);
OnConnectionStateChanged?.Invoke(HubConnectionState.Connected);
return Task.CompletedTask;
};
_hubConnection.Closed += async error =>
{
_logger.LogWarning(error, "SignalR connection closed");
OnConnectionStateChanged?.Invoke(HubConnectionState.Disconnected);
await Task.CompletedTask;
};
}
public async ValueTask DisposeAsync()
{
if (_hubConnection != null)
{
await _hubConnection.DisposeAsync();
}
}
}
/// <summary>
/// Event arguments for token revoked notification
/// </summary>
public class TokenRevokedEventArgs
{
public long UserId { get; set; }
public string Reason { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
}
/// <summary>
/// Event arguments for force refresh token notification
/// </summary>
public class ForceRefreshEventArgs
{
public long UserId { get; set; }
public DateTime Timestamp { get; set; }
}
/// <summary>
/// Event arguments for broadcast message notification
/// </summary>
public class BroadcastMessageEventArgs
{
public string Message { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
}