feat: integrate d3-org-chart for network visualization and add SignalR token notification service
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user