Skip to content

Commit 67bf09b

Browse files
committed
Add review workflow, IMAP mail backend, org project routes, and CLI token refresh
- Add review/approval workflow for translations (4 states: pending, translated, reviewed, approved) - Add ProjectReviewer and OrganizationReviewer entities - Add ReviewWorkflowService and ReviewWorkflowController - Add IMAP mail backend support with configurable backend selection - Add OrgProjectsController for organization project access by slug - Fix CLI cloud commands to auto-refresh expired JWT tokens - Update CLI RemoteUrl to use /api/organizations/{slug}/projects/{slug} route - Add comprehensive tests for ReviewWorkflow, TranslationMemory, and Glossary services - Update config.sample.json and setup.sh for mail backend configuration
1 parent 71d7ed5 commit 67bf09b

46 files changed

Lines changed: 8531 additions & 123 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Commands/Cloud/CloneCommand.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ public override int Execute(CommandContext context, CloneCommandSettings setting
143143

144144
// 7. Fetch and validate remote project
145145
using var apiClient = new CloudApiClient(remoteUrl);
146-
ConfigureAuth(apiClient, cloudConfig);
146+
ConfigureAuth(apiClient, cloudConfig, targetDirectory);
147147

148148
CloudProject? remoteProject = null;
149149
try
@@ -339,7 +339,7 @@ private bool Authenticate(RemoteUrl remoteUrl, CloudConfig config, CloneCommandS
339339
}
340340
}
341341

342-
private static void ConfigureAuth(CloudApiClient apiClient, CloudConfig config)
342+
private static void ConfigureAuth(CloudApiClient apiClient, CloudConfig config, string? projectDirectory = null)
343343
{
344344
if (!string.IsNullOrWhiteSpace(config.ApiKey))
345345
{
@@ -348,6 +348,11 @@ private static void ConfigureAuth(CloudApiClient apiClient, CloudConfig config)
348348
else
349349
{
350350
apiClient.SetAccessToken(config.AccessToken);
351+
// Enable auto-refresh for JWT authentication
352+
if (!string.IsNullOrEmpty(projectDirectory))
353+
{
354+
apiClient.EnableAutoRefresh(projectDirectory);
355+
}
351356
}
352357
}
353358

@@ -442,7 +447,7 @@ private int PullResources(string targetDirectory, RemoteUrl remoteUrl, CloudConf
442447
AnsiConsole.Status()
443448
.Start("Fetching resources...", ctx =>
444449
{
445-
pullResponse = apiClient.PullResourcesAsync(ct).GetAwaiter().GetResult();
450+
pullResponse = apiClient.PullResourcesAsync(includeUnapproved: false, ct).GetAwaiter().GetResult();
446451
});
447452
}
448453
catch (CloudApiException ex)

Commands/Cloud/InitCommand.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ private int InitInteractive(string projectDirectory, CloudConfig config, CloudIn
134134
}
135135

136136
// 3. Fetch user's projects
137-
var projects = FetchUserProjects(host, port, useHttps, config, cancellationToken);
137+
var projects = FetchUserProjects(projectDirectory, host, port, useHttps, config, cancellationToken);
138138

139139
// 4. Select or create project
140140
CloudProject? selectedProject = null;
@@ -314,7 +314,7 @@ private RemoteUrl CreateAuthRemoteUrl(string host, int port, bool useHttps)
314314
}
315315

316316
private List<CloudProject> FetchUserProjects(
317-
string host, int port, bool useHttps, CloudConfig config, CancellationToken cancellationToken)
317+
string projectDirectory, string host, int port, bool useHttps, CloudConfig config, CancellationToken cancellationToken)
318318
{
319319
var remoteUrl = CreateAuthRemoteUrl(host, port, useHttps);
320320
List<CloudProject> projects = new();
@@ -331,6 +331,8 @@ private List<CloudProject> FetchUserProjects(
331331
else
332332
{
333333
apiClient.SetAccessToken(config.AccessToken);
334+
// Enable auto-refresh for JWT authentication
335+
apiClient.EnableAutoRefresh(projectDirectory);
334336
}
335337

336338
projects = apiClient.GetUserProjectsAsync(cancellationToken).GetAwaiter().GetResult();
@@ -481,6 +483,8 @@ private List<CloudProject> FetchUserProjects(
481483
else
482484
{
483485
apiClient.SetAccessToken(config.AccessToken);
486+
// Enable auto-refresh for JWT authentication
487+
apiClient.EnableAutoRefresh(projectDirectory);
484488
}
485489

486490
var request = new CreateProjectRequest

Commands/Cloud/PullCommand.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ public class PullCommandSettings : BaseCommandSettings
4545
[Description("Pull only resources, skip configuration")]
4646
[DefaultValue(false)]
4747
public bool ResourcesOnly { get; set; }
48+
49+
[CommandOption("--include-unapproved")]
50+
[Description("Include translations that haven't been approved yet (when project has review workflow enabled)")]
51+
[DefaultValue(false)]
52+
public bool IncludeUnapproved { get; set; }
4853
}
4954

5055
/// <summary>
@@ -128,6 +133,8 @@ public override int Execute(CommandContext context, PullCommandSettings settings
128133
else
129134
{
130135
apiClient.SetAccessToken(cloudConfig.AccessToken);
136+
// Enable auto-refresh for JWT authentication
137+
apiClient.EnableAutoRefresh(projectDirectory);
131138
}
132139

133140
// Fetch remote project info and validate format compatibility
@@ -184,14 +191,21 @@ public override int Execute(CommandContext context, PullCommandSettings settings
184191
AnsiConsole.Status()
185192
.Start("Fetching remote data...", ctx =>
186193
{
187-
pullResponse = apiClient.PullResourcesAsync(cancellationToken).GetAwaiter().GetResult();
194+
pullResponse = apiClient.PullResourcesAsync(settings.IncludeUnapproved, cancellationToken).GetAwaiter().GetResult();
188195
});
189196

190197
if (pullResponse == null)
191198
{
192199
throw new InvalidOperationException("Failed to pull resources from server");
193200
}
194201

202+
// Show workflow message if translations were excluded
203+
if (!string.IsNullOrEmpty(pullResponse.WorkflowMessage))
204+
{
205+
AnsiConsole.MarkupLine($"[yellow]⚠ {pullResponse.WorkflowMessage.EscapeMarkup()}[/]");
206+
AnsiConsole.WriteLine();
207+
}
208+
195209
// Detect conflicts and show diff
196210
var conflictDetector = new ConflictDetector();
197211
var conflicts = new List<ConflictDetector.Conflict>();

Commands/Cloud/PushCommand.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ public override int Execute(CommandContext context, PushCommandSettings settings
138138
else
139139
{
140140
apiClient.SetAccessToken(cloudConfig.AccessToken);
141+
// Enable auto-refresh for JWT authentication
142+
apiClient.EnableAutoRefresh(projectDirectory);
141143
}
142144

143145
// Fetch remote project info and validate format compatibility

Commands/Cloud/StatusCommand.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ private int DisplaySyncStatus(CloudConfig config, string projectDirectory, Core.
104104
else
105105
{
106106
apiClient.SetAccessToken(config.AccessToken);
107+
// Enable auto-refresh for JWT authentication
108+
apiClient.EnableAutoRefresh(projectDirectory);
107109
}
108110

109111
// Fetch sync status
@@ -193,6 +195,8 @@ private int DisplayAccountStatus(CloudConfig config, string projectDirectory, Co
193195
else
194196
{
195197
apiClient.SetAccessToken(config.AccessToken);
198+
// Enable auto-refresh for JWT authentication
199+
apiClient.EnableAutoRefresh(projectDirectory);
196200
}
197201

198202
// Fetch user info

LocalizationManager.Core/Cloud/CloudApiClient.cs

Lines changed: 138 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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>

LocalizationManager.Core/Cloud/RemoteUrl.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public string ProjectApiUrl
5959
if (IsPersonalProject)
6060
return $"{ApiBaseUrl}/users/{Username}/projects/{ProjectName}";
6161
else
62-
return $"{ApiBaseUrl}/projects/{Organization}/{ProjectName}";
62+
return $"{ApiBaseUrl}/organizations/{Organization}/projects/{ProjectName}";
6363
}
6464
}
6565

cloud/deploy/config.sample.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,24 @@
2828
"jwtExpiryHours": 24
2929
},
3030
"mail": {
31+
"backend": "smtp",
3132
"host": "smtp.example.com",
3233
"port": 587,
3334
"username": "YOUR_SMTP_USERNAME",
3435
"password": "YOUR_SMTP_PASSWORD",
3536
"fromAddress": "noreply@your-domain.com",
3637
"fromName": "LRM Cloud",
37-
"useSsl": true
38+
"useSsl": true,
39+
"imap": {
40+
"host": "imap.example.com",
41+
"port": 993,
42+
"useSsl": true,
43+
"smtpHost": "smtp.example.com",
44+
"smtpPort": 587,
45+
"smtpUseSsl": false,
46+
"sentFolder": "Sent",
47+
"saveToSent": true
48+
}
3849
},
3950
"features": {
4051
"registration": true,

0 commit comments

Comments
 (0)