Skip to content

Commit 01e55ba

Browse files
committed
Fix plural translation display and token refresh race condition
- Add RefreshActivePluralForms() to KeyDetailDrawer for external refresh - Call refresh after applying translations to show new plural cells - Add TokenRefreshCoordinator to prevent concurrent token refreshes - Remove duplicate _isRefreshing flags from AuthenticatedHttpHandler and LrmAuthStateProvider in favor of centralized coordination
1 parent 86ed488 commit 01e55ba

7 files changed

Lines changed: 136 additions & 31 deletions

File tree

cloud/src/LrmCloud.Web/Components/KeyDetailDrawer.razor

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,15 @@
282282
private HashSet<string> _activePluralForms = new();
283283

284284
protected override void OnParametersSet()
285+
{
286+
RefreshActivePluralForms();
287+
}
288+
289+
/// <summary>
290+
/// Refreshes the active plural forms from the current Row state.
291+
/// Call this after externally modifying Row.Translations (e.g., after translation).
292+
/// </summary>
293+
public void RefreshActivePluralForms()
285294
{
286295
// Initialize active plural forms from existing translations
287296
_activePluralForms = GetExistingPluralForms();
@@ -297,6 +306,8 @@
297306
{
298307
_activePluralForms.Add(PluralForms.One);
299308
}
309+
310+
StateHasChanged();
300311
}
301312

302313
private bool HasChanges => Row.Translations.Values.Any(t => t.IsDirty);

cloud/src/LrmCloud.Web/Pages/Projects/Editor.razor

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ else
117117
<MudDrawerContainer Class="pa-4">
118118
@if (_selectedRow != null)
119119
{
120-
<KeyDetailDrawer Row="@_selectedRow"
120+
<KeyDetailDrawer @ref="_keyDetailDrawer"
121+
Row="@_selectedRow"
121122
Languages="@_languages"
122123
DefaultLanguage="@_project.DefaultLanguage"
123124
ProjectFormat="@_project.Format"
@@ -181,6 +182,7 @@ else
181182
private bool _drawerOpen;
182183
private TranslationGridRow? _selectedRow; // Clone for drawer editing (isolated)
183184
private TranslationGridRow? _originalSelectedRow; // Original row reference (in grid)
185+
private KeyDetailDrawer? _keyDetailDrawer;
184186
private AddKeyDialog? _addKeyDialog;
185187
private ConfirmDeleteDialog? _deleteDialog;
186188
private IEnumerable<TranslationGridRow>? _pendingDeleteRows;
@@ -777,6 +779,8 @@ else
777779

778780
if (appliedCount > 0)
779781
{
782+
// Refresh the drawer's plural form display to show new cells
783+
_keyDetailDrawer?.RefreshActivePluralForms();
780784
Snackbar.Add($"Applied {appliedCount} translations. Click Apply to stage changes.", Severity.Success);
781785
StateHasChanged();
782786
}

cloud/src/LrmCloud.Web/Program.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
return new HttpClient(handler) { BaseAddress = apiBaseUrl };
3636
});
3737

38+
// Token refresh coordinator (singleton to coordinate across all service instances)
39+
builder.Services.AddSingleton<TokenRefreshCoordinator>();
40+
3841
// Auth service (depends on HttpClient, so register after)
3942
builder.Services.AddScoped<AuthService>();
4043

cloud/src/LrmCloud.Web/Services/AuthService.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ public class AuthService
1414
private readonly HttpClient _httpClient;
1515
private readonly TokenStorageService _tokenStorage;
1616
private readonly LrmAuthStateProvider _authStateProvider;
17+
private readonly TokenRefreshCoordinator _refreshCoordinator;
1718

18-
public AuthService(HttpClient httpClient, TokenStorageService tokenStorage, LrmAuthStateProvider authStateProvider)
19+
public AuthService(HttpClient httpClient, TokenStorageService tokenStorage, LrmAuthStateProvider authStateProvider, TokenRefreshCoordinator refreshCoordinator)
1920
{
2021
_httpClient = httpClient;
2122
_tokenStorage = tokenStorage;
2223
_authStateProvider = authStateProvider;
24+
_refreshCoordinator = refreshCoordinator;
2325
}
2426

2527
public async Task<AuthResult> LoginAsync(LoginRequest request)
@@ -138,6 +140,18 @@ public async Task<AuthResult> VerifyEmailAsync(string token)
138140

139141
public async Task<bool> RefreshTokenAsync()
140142
{
143+
// Use coordinator to prevent concurrent refresh attempts
144+
if (!await _refreshCoordinator.TryAcquireRefreshLockAsync())
145+
{
146+
// Another refresh is in progress or was recently attempted
147+
// Wait for it to complete and check if tokens are now valid
148+
await _refreshCoordinator.WaitForRefreshAsync(TimeSpan.FromSeconds(5));
149+
150+
// Check if we now have valid tokens (from the other refresh)
151+
var token = await _tokenStorage.GetAccessTokenAsync();
152+
return !string.IsNullOrEmpty(token) && !await _tokenStorage.IsTokenExpiredAsync();
153+
}
154+
141155
try
142156
{
143157
var refreshToken = await _tokenStorage.GetRefreshTokenAsync();
@@ -180,6 +194,10 @@ await _tokenStorage.StoreTokensAsync(
180194
// The user may still be able to retry later
181195
return false;
182196
}
197+
finally
198+
{
199+
_refreshCoordinator.ReleaseRefreshLock();
200+
}
183201
}
184202

185203
public async Task LogoutAsync()

cloud/src/LrmCloud.Web/Services/AuthenticatedHttpHandler.cs

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ public class AuthenticatedHttpHandler : DelegatingHandler
1010
{
1111
private readonly TokenStorageService _tokenStorage;
1212
private readonly IServiceProvider _serviceProvider;
13-
private bool _isRefreshing;
1413

1514
public AuthenticatedHttpHandler(TokenStorageService tokenStorage, IServiceProvider serviceProvider)
1615
{
@@ -34,7 +33,7 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
3433
var response = await base.SendAsync(request, cancellationToken);
3534

3635
// Handle 401 Unauthorized - try to refresh token
37-
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && !isAuthEndpoint && !_isRefreshing)
36+
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && !isAuthEndpoint)
3837
{
3938
if (await TryRefreshTokenAsync())
4039
{
@@ -59,26 +58,16 @@ private async Task AddAuthHeaderAsync(HttpRequestMessage request)
5958

6059
private async Task<bool> TryRefreshTokenAsync()
6160
{
62-
if (_isRefreshing)
61+
if (!await _tokenStorage.CanRefreshAsync())
6362
return false;
6463

65-
_isRefreshing = true;
66-
try
67-
{
68-
if (!await _tokenStorage.CanRefreshAsync())
69-
return false;
70-
71-
// Get AuthService from service provider (avoiding circular dependency)
72-
var authService = _serviceProvider.GetService<AuthService>();
73-
if (authService == null)
74-
return false;
64+
// Get AuthService from service provider (avoiding circular dependency)
65+
// AuthService now uses TokenRefreshCoordinator internally for synchronization
66+
var authService = _serviceProvider.GetService<AuthService>();
67+
if (authService == null)
68+
return false;
7569

76-
return await authService.RefreshTokenAsync();
77-
}
78-
finally
79-
{
80-
_isRefreshing = false;
81-
}
70+
return await authService.RefreshTokenAsync();
8271
}
8372

8473
private static async Task<HttpRequestMessage> CloneRequestAsync(HttpRequestMessage request)

cloud/src/LrmCloud.Web/Services/LrmAuthStateProvider.cs

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ public class LrmAuthStateProvider : AuthenticationStateProvider
1515
private readonly IServiceProvider _serviceProvider;
1616
private UserDto? _cachedUser;
1717
private bool _isInitialized;
18-
private bool _isRefreshing;
1918

2019
public LrmAuthStateProvider(TokenStorageService tokenStorage, HttpClient httpClient, IServiceProvider serviceProvider)
2120
{
@@ -37,12 +36,12 @@ public override async Task<AuthenticationState> GetAuthenticationStateAsync()
3736
if (await _tokenStorage.IsTokenExpiredAsync())
3837
{
3938
// Token expired, try to refresh if possible
40-
if (await _tokenStorage.CanRefreshAsync() && !_isRefreshing)
39+
if (await _tokenStorage.CanRefreshAsync())
4140
{
42-
_isRefreshing = true;
4341
try
4442
{
4543
// Attempt to refresh the token using AuthService
44+
// AuthService uses TokenRefreshCoordinator internally to prevent concurrent refreshes
4645
var authService = _serviceProvider.GetService<AuthService>();
4746
if (authService != null && await authService.RefreshTokenAsync())
4847
{
@@ -61,17 +60,12 @@ public override async Task<AuthenticationState> GetAuthenticationStateAsync()
6160
// Error during refresh - return unauthenticated
6261
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
6362
}
64-
finally
65-
{
66-
_isRefreshing = false;
67-
}
6863
}
69-
else if (!_isRefreshing)
64+
else
7065
{
71-
// Can't refresh and not currently refreshing - return unauthenticated
66+
// Can't refresh - return unauthenticated
7267
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
7368
}
74-
// If _isRefreshing is true, we're in a refresh cycle, proceed with current token
7569
}
7670

7771
// Try to get user info from cache or fetch from API
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
namespace LrmCloud.Web.Services;
2+
3+
/// <summary>
4+
/// Coordinates token refresh attempts across different parts of the app
5+
/// to prevent race conditions when multiple components try to refresh simultaneously.
6+
/// </summary>
7+
public class TokenRefreshCoordinator
8+
{
9+
private readonly SemaphoreSlim _refreshLock = new(1, 1);
10+
private bool _refreshInProgress;
11+
private DateTime? _lastRefreshAttempt;
12+
13+
/// <summary>
14+
/// Minimum time between refresh attempts to prevent rapid-fire refreshes.
15+
/// </summary>
16+
private static readonly TimeSpan MinRefreshInterval = TimeSpan.FromSeconds(5);
17+
18+
/// <summary>
19+
/// Try to acquire the refresh lock. Returns true if this caller should perform the refresh.
20+
/// Returns false if another refresh is in progress or was recently attempted.
21+
/// </summary>
22+
public async Task<bool> TryAcquireRefreshLockAsync(CancellationToken cancellationToken = default)
23+
{
24+
// Quick check before waiting for lock
25+
if (_refreshInProgress)
26+
return false;
27+
28+
// Check if refresh was recently attempted (even if it failed)
29+
if (_lastRefreshAttempt.HasValue &&
30+
DateTime.UtcNow - _lastRefreshAttempt.Value < MinRefreshInterval)
31+
return false;
32+
33+
// Try to acquire the lock with a short timeout
34+
if (!await _refreshLock.WaitAsync(TimeSpan.FromMilliseconds(100), cancellationToken))
35+
return false;
36+
37+
// Double-check after acquiring lock
38+
if (_refreshInProgress)
39+
{
40+
_refreshLock.Release();
41+
return false;
42+
}
43+
44+
_refreshInProgress = true;
45+
_lastRefreshAttempt = DateTime.UtcNow;
46+
return true;
47+
}
48+
49+
/// <summary>
50+
/// Release the refresh lock after refresh attempt completes.
51+
/// </summary>
52+
public void ReleaseRefreshLock()
53+
{
54+
_refreshInProgress = false;
55+
try
56+
{
57+
_refreshLock.Release();
58+
}
59+
catch (SemaphoreFullException)
60+
{
61+
// Already released, ignore
62+
}
63+
}
64+
65+
/// <summary>
66+
/// Check if a refresh is currently in progress.
67+
/// </summary>
68+
public bool IsRefreshInProgress => _refreshInProgress;
69+
70+
/// <summary>
71+
/// Wait for any ongoing refresh to complete.
72+
/// Returns true if we waited, false if no refresh was in progress.
73+
/// </summary>
74+
public async Task<bool> WaitForRefreshAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
75+
{
76+
if (!_refreshInProgress)
77+
return false;
78+
79+
var startTime = DateTime.UtcNow;
80+
while (_refreshInProgress && DateTime.UtcNow - startTime < timeout)
81+
{
82+
await Task.Delay(50, cancellationToken);
83+
}
84+
return true;
85+
}
86+
}

0 commit comments

Comments
 (0)