@@ -198,9 +198,18 @@ public async Task<PushResponse> PushResourcesAsync(
198198 /// <summary>
199199 /// Pulls files from the cloud (V2 - generates files from database).
200200 /// </summary>
201- public async Task < PullResponse > PullResourcesAsync ( CancellationToken cancellationToken = default )
201+ /// <param name="includeUnapproved">If true, include all translations regardless of workflow approval status.
202+ /// If false (default), only include approved translations when project requires approval before export.</param>
203+ /// <param name="cancellationToken">Cancellation token.</param>
204+ public async Task < PullResponse > PullResourcesAsync (
205+ bool includeUnapproved = false ,
206+ CancellationToken cancellationToken = default )
202207 {
203208 var url = $ "{ _remoteUrl . ProjectApiUrl } /sync/pull";
209+ if ( includeUnapproved )
210+ {
211+ url += "?includeUnapproved=true" ;
212+ }
204213 var response = await GetAsync < PullResponse > ( url , cancellationToken ) ;
205214 return response ?? throw new CloudApiException ( "Failed to pull resources" ) ;
206215 }
@@ -277,11 +286,87 @@ public async Task<List<CloudOrganization>> GetUserOrganizationsAsync(Cancellatio
277286 return response ?? new List < CloudOrganization > ( ) ;
278287 }
279288
280- private Task < bool > TryRefreshTokenAsync ( CancellationToken cancellationToken = default )
289+ private async Task < bool > TryRefreshTokenAsync ( CancellationToken cancellationToken = default )
281290 {
282- // Auto-refresh is currently disabled.
283- // Callers should handle token refresh externally using CloudConfigManager.
284- return Task . FromResult ( false ) ;
291+ // Check if auto-refresh is enabled
292+ if ( string . IsNullOrEmpty ( _projectDirectory ) )
293+ {
294+ return false ;
295+ }
296+
297+ try
298+ {
299+ // Load current cloud config to get refresh token
300+ var cloudConfig = await CloudConfigManager . LoadAsync ( _projectDirectory , cancellationToken ) ;
301+
302+ if ( string . IsNullOrWhiteSpace ( cloudConfig . RefreshToken ) )
303+ {
304+ return false ;
305+ }
306+
307+ // Check if refresh token is still valid
308+ if ( cloudConfig . RefreshTokenExpiresAt . HasValue && cloudConfig . RefreshTokenExpiresAt . Value <= DateTime . UtcNow )
309+ {
310+ return false ;
311+ }
312+
313+ // Call refresh endpoint directly (bypass normal request path to avoid recursion)
314+ var url = $ "{ _remoteUrl . ApiBaseUrl } /auth/refresh";
315+ var request = new RefreshTokenRequest { RefreshToken = cloudConfig . RefreshToken } ;
316+
317+ // Remove auth header temporarily to avoid sending expired token
318+ var currentAuth = _httpClient . DefaultRequestHeaders . Authorization ;
319+ _httpClient . DefaultRequestHeaders . Authorization = null ;
320+
321+ try
322+ {
323+ var response = await _httpClient . PostAsJsonAsync ( url , request , _jsonOptions , cancellationToken ) ;
324+ if ( ! response . IsSuccessStatusCode )
325+ {
326+ return false ;
327+ }
328+
329+ var apiResponse = await response . Content . ReadFromJsonAsync < ApiResponse < LoginResponse > > ( _jsonOptions , cancellationToken ) ;
330+ var loginResponse = apiResponse ? . Data ;
331+
332+ if ( loginResponse == null || string . IsNullOrWhiteSpace ( loginResponse . Token ) )
333+ {
334+ return false ;
335+ }
336+
337+ // Update the access token in this client
338+ SetAccessToken ( loginResponse . Token ) ;
339+
340+ // Save the new tokens to config
341+ await CloudConfigManager . SetAuthenticationAsync (
342+ _projectDirectory ,
343+ loginResponse . Token ,
344+ loginResponse . ExpiresAt ,
345+ loginResponse . RefreshToken ,
346+ loginResponse . RefreshTokenExpiresAt ,
347+ cancellationToken ) ;
348+
349+ // Notify callback if registered
350+ if ( _onTokenRefreshed != null )
351+ {
352+ await _onTokenRefreshed ( ) ;
353+ }
354+
355+ return true ;
356+ }
357+ finally
358+ {
359+ // Restore auth header if refresh failed
360+ if ( _httpClient . DefaultRequestHeaders . Authorization == null && currentAuth != null )
361+ {
362+ _httpClient . DefaultRequestHeaders . Authorization = currentAuth ;
363+ }
364+ }
365+ }
366+ catch
367+ {
368+ return false ;
369+ }
285370 }
286371
287372 #endregion
@@ -293,6 +378,13 @@ private Task<bool> TryRefreshTokenAsync(CancellationToken cancellationToken = de
293378 try
294379 {
295380 var response = await _httpClient . GetAsync ( url , cancellationToken ) ;
381+
382+ // Try to refresh token and retry once on 401
383+ if ( ! response . IsSuccessStatusCode && await ShouldRetryAfterTokenRefreshAsync ( response , cancellationToken ) )
384+ {
385+ response = await _httpClient . GetAsync ( url , cancellationToken ) ;
386+ }
387+
296388 await EnsureSuccessAsync ( response , cancellationToken ) ;
297389 var apiResponse = await response . Content . ReadFromJsonAsync < ApiResponse < T > > ( _jsonOptions , cancellationToken ) ;
298390 return apiResponse != null ? apiResponse . Data : default ;
@@ -308,6 +400,13 @@ private Task<bool> TryRefreshTokenAsync(CancellationToken cancellationToken = de
308400 try
309401 {
310402 var response = await _httpClient . PostAsJsonAsync ( url , request , _jsonOptions , cancellationToken ) ;
403+
404+ // Try to refresh token and retry once on 401
405+ if ( ! response . IsSuccessStatusCode && await ShouldRetryAfterTokenRefreshAsync ( response , cancellationToken ) )
406+ {
407+ response = await _httpClient . PostAsJsonAsync ( url , request , _jsonOptions , cancellationToken ) ;
408+ }
409+
311410 await EnsureSuccessAsync ( response , cancellationToken ) ;
312411 var apiResponse = await response . Content . ReadFromJsonAsync < ApiResponse < T > > ( _jsonOptions , cancellationToken ) ;
313412 return apiResponse != null ? apiResponse . Data : default ;
@@ -323,6 +422,13 @@ private Task<bool> TryRefreshTokenAsync(CancellationToken cancellationToken = de
323422 try
324423 {
325424 var response = await _httpClient . PutAsJsonAsync ( url , request , _jsonOptions , cancellationToken ) ;
425+
426+ // Try to refresh token and retry once on 401
427+ if ( ! response . IsSuccessStatusCode && await ShouldRetryAfterTokenRefreshAsync ( response , cancellationToken ) )
428+ {
429+ response = await _httpClient . PutAsJsonAsync ( url , request , _jsonOptions , cancellationToken ) ;
430+ }
431+
326432 await EnsureSuccessAsync ( response , cancellationToken ) ;
327433 var apiResponse = await response . Content . ReadFromJsonAsync < ApiResponse < T > > ( _jsonOptions , cancellationToken ) ;
328434 return apiResponse != null ? apiResponse . Data : default ;
@@ -333,23 +439,25 @@ private Task<bool> TryRefreshTokenAsync(CancellationToken cancellationToken = de
333439 }
334440 }
335441
442+ /// <summary>
443+ /// Checks if a 401 response can be retried after token refresh.
444+ /// Returns true if the token was refreshed and the request should be retried.
445+ /// </summary>
446+ private async Task < bool > ShouldRetryAfterTokenRefreshAsync ( HttpResponseMessage response , CancellationToken cancellationToken )
447+ {
448+ if ( response . StatusCode == System . Net . HttpStatusCode . Unauthorized
449+ && ! IsUsingApiKey // Don't refresh for API key auth
450+ && ! string . IsNullOrEmpty ( _projectDirectory ) )
451+ {
452+ return await TryRefreshTokenAsync ( cancellationToken ) ;
453+ }
454+ return false ;
455+ }
456+
336457 private async Task EnsureSuccessAsync ( HttpResponseMessage response , CancellationToken cancellationToken = default )
337458 {
338459 if ( ! response . IsSuccessStatusCode )
339460 {
340- // If 401 and using JWT (not API key) with auto-refresh enabled, try to refresh token
341- if ( response . StatusCode == System . Net . HttpStatusCode . Unauthorized
342- && ! IsUsingApiKey // Don't refresh for API key auth
343- && ! string . IsNullOrEmpty ( _projectDirectory ) )
344- {
345- var refreshed = await TryRefreshTokenAsync ( cancellationToken ) ;
346- if ( refreshed )
347- {
348- // Token refreshed - throw special exception so caller can retry
349- throw new CloudApiException ( "Token expired and refreshed" , 401 ) ;
350- }
351- }
352-
353461 var errorContent = await response . Content . ReadAsStringAsync ( ) ;
354462 var statusCode = ( int ) response . StatusCode ;
355463
@@ -507,6 +615,18 @@ public class PullResponse
507615{
508616 public string ? Configuration { get ; set ; }
509617 public List < FileDto > Files { get ; set ; } = new ( ) ;
618+
619+ /// <summary>
620+ /// Number of translations excluded due to workflow requirements.
621+ /// Only populated when project has review workflow enabled.
622+ /// </summary>
623+ public int ExcludedTranslationCount { get ; set ; }
624+
625+ /// <summary>
626+ /// Informational message about excluded translations due to workflow.
627+ /// Null if no translations were excluded.
628+ /// </summary>
629+ public string ? WorkflowMessage { get ; set ; }
510630}
511631
512632/// <summary>
0 commit comments