Skip to content

Commit c9ff719

Browse files
committed
Add secure credential storage UI integration
- Add CredentialsController for Web API credential management - Add secure store toggle checkbox to Settings panel - Show key source indicators (environment/secure/config/none) - Wire saveSettings() to use /api/credentials/{provider} when secure store enabled - Add credential methods to apiClient.ts (setApiKey, deleteApiKey, testProvider) - Fix provider name mappings (claude, azuretranslator) - Update API.md and README.md documentation
1 parent f278875 commit c9ff719

6 files changed

Lines changed: 857 additions & 108 deletions

File tree

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
// Copyright (c) 2025 Nikolaos Protopapas
2+
// Licensed under the MIT License
3+
4+
using Microsoft.AspNetCore.Mvc;
5+
using LocalizationManager.Core.Configuration;
6+
using LocalizationManager.Core.Translation;
7+
using LocalizationManager.Models.Api;
8+
9+
namespace LocalizationManager.Controllers;
10+
11+
[ApiController]
12+
[Route("api/[controller]")]
13+
public class CredentialsController : ControllerBase
14+
{
15+
private readonly ConfigurationService _configService;
16+
private readonly string _resourcePath;
17+
18+
public CredentialsController(IConfiguration configuration, ConfigurationService configService)
19+
{
20+
_configService = configService;
21+
_resourcePath = configuration["ResourcePath"] ?? Directory.GetCurrentDirectory();
22+
}
23+
24+
/// <summary>
25+
/// Get list of all providers with their credential source status
26+
/// </summary>
27+
[HttpGet("providers")]
28+
public ActionResult<CredentialProvidersResponse> GetProviders()
29+
{
30+
try
31+
{
32+
var config = _configService.GetConfiguration();
33+
var providerInfos = TranslationProviderFactory.GetProviderInfos();
34+
var secureProviders = SecureCredentialManager.GetConfiguredProviders();
35+
36+
var providers = providerInfos.Select(p =>
37+
{
38+
string? source = null;
39+
40+
// Check environment variable first (highest priority)
41+
var envVar = $"LRM_{p.Name.ToUpperInvariant()}_API_KEY";
42+
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(envVar)))
43+
{
44+
source = "environment";
45+
}
46+
// Check secure store
47+
else if (secureProviders.Contains(p.Name, StringComparer.OrdinalIgnoreCase))
48+
{
49+
source = "secure_store";
50+
}
51+
// Check config file
52+
else if (!string.IsNullOrWhiteSpace(config?.Translation?.ApiKeys?.GetKeyForProvider(p.Name)))
53+
{
54+
source = "config_file";
55+
}
56+
57+
return new CredentialProviderInfo
58+
{
59+
Provider = p.Name,
60+
DisplayName = p.DisplayName,
61+
RequiresApiKey = p.RequiresApiKey,
62+
Source = source,
63+
IsConfigured = source != null || !p.RequiresApiKey
64+
};
65+
}).ToList();
66+
67+
return Ok(new CredentialProvidersResponse
68+
{
69+
Providers = providers,
70+
UseSecureCredentialStore = config?.Translation?.UseSecureCredentialStore ?? false
71+
});
72+
}
73+
catch (Exception)
74+
{
75+
return StatusCode(500, new ErrorResponse { Error = "An error occurred while processing your request" });
76+
}
77+
}
78+
79+
/// <summary>
80+
/// Set API key in secure credential store
81+
/// </summary>
82+
[HttpPut("{provider}")]
83+
public ActionResult<OperationResponse> SetApiKey(string provider, [FromBody] SetApiKeyRequest request)
84+
{
85+
try
86+
{
87+
if (string.IsNullOrWhiteSpace(provider))
88+
{
89+
return BadRequest(new ErrorResponse { Error = "Provider name is required" });
90+
}
91+
92+
if (string.IsNullOrWhiteSpace(request?.ApiKey))
93+
{
94+
return BadRequest(new ErrorResponse { Error = "API key is required" });
95+
}
96+
97+
// Validate provider name
98+
var validProviders = TranslationProviderFactory.GetProviderInfos()
99+
.Where(p => p.RequiresApiKey)
100+
.Select(p => p.Name.ToLowerInvariant())
101+
.ToHashSet();
102+
103+
if (!validProviders.Contains(provider.ToLowerInvariant()))
104+
{
105+
return BadRequest(new ErrorResponse { Error = $"Unknown or keyless provider: {provider}" });
106+
}
107+
108+
// Store in secure credential store
109+
SecureCredentialManager.SetApiKey(provider.ToLowerInvariant(), request.ApiKey);
110+
111+
// Enable secure credential store in config if not already
112+
var config = _configService.GetConfiguration() ?? new ConfigurationModel();
113+
if (config.Translation == null)
114+
{
115+
config.Translation = new TranslationConfiguration();
116+
}
117+
if (!config.Translation.UseSecureCredentialStore)
118+
{
119+
config.Translation.UseSecureCredentialStore = true;
120+
_configService.SaveConfiguration(config);
121+
}
122+
123+
return Ok(new OperationResponse
124+
{
125+
Success = true,
126+
Message = $"API key for {provider} stored securely"
127+
});
128+
}
129+
catch (Exception)
130+
{
131+
return StatusCode(500, new ErrorResponse { Error = "An error occurred while processing your request" });
132+
}
133+
}
134+
135+
/// <summary>
136+
/// Delete API key from secure credential store
137+
/// </summary>
138+
[HttpDelete("{provider}")]
139+
public ActionResult<OperationResponse> DeleteApiKey(string provider)
140+
{
141+
try
142+
{
143+
if (string.IsNullOrWhiteSpace(provider))
144+
{
145+
return BadRequest(new ErrorResponse { Error = "Provider name is required" });
146+
}
147+
148+
var deleted = SecureCredentialManager.DeleteApiKey(provider.ToLowerInvariant());
149+
150+
if (deleted)
151+
{
152+
return Ok(new OperationResponse
153+
{
154+
Success = true,
155+
Message = $"API key for {provider} removed from secure store"
156+
});
157+
}
158+
else
159+
{
160+
return Ok(new OperationResponse
161+
{
162+
Success = false,
163+
Message = $"No API key found for {provider} in secure store"
164+
});
165+
}
166+
}
167+
catch (Exception)
168+
{
169+
return StatusCode(500, new ErrorResponse { Error = "An error occurred while processing your request" });
170+
}
171+
}
172+
173+
/// <summary>
174+
/// Get the source of an API key (for display purposes, never returns the actual key)
175+
/// </summary>
176+
[HttpGet("{provider}/source")]
177+
public ActionResult<CredentialSourceResponse> GetApiKeySource(string provider)
178+
{
179+
try
180+
{
181+
if (string.IsNullOrWhiteSpace(provider))
182+
{
183+
return BadRequest(new ErrorResponse { Error = "Provider name is required" });
184+
}
185+
186+
var config = _configService.GetConfiguration();
187+
var source = ApiKeyResolver.GetApiKeySource(provider.ToLowerInvariant(), config);
188+
189+
return Ok(new CredentialSourceResponse
190+
{
191+
Provider = provider,
192+
Source = source,
193+
IsConfigured = source != null
194+
});
195+
}
196+
catch (Exception)
197+
{
198+
return StatusCode(500, new ErrorResponse { Error = "An error occurred while processing your request" });
199+
}
200+
}
201+
202+
/// <summary>
203+
/// Test provider connection with configured credentials
204+
/// </summary>
205+
[HttpPost("{provider}/test")]
206+
public async Task<ActionResult<ProviderTestResponse>> TestProvider(string provider)
207+
{
208+
try
209+
{
210+
if (string.IsNullOrWhiteSpace(provider))
211+
{
212+
return BadRequest(new ErrorResponse { Error = "Provider name is required" });
213+
}
214+
215+
var config = _configService.GetConfiguration();
216+
217+
// Check if provider has credentials configured
218+
var apiKey = ApiKeyResolver.GetApiKey(provider.ToLowerInvariant(), config);
219+
var providerInfo = TranslationProviderFactory.GetProviderInfos()
220+
.FirstOrDefault(p => p.Name.Equals(provider, StringComparison.OrdinalIgnoreCase));
221+
222+
if (providerInfo == null)
223+
{
224+
return BadRequest(new ErrorResponse { Error = $"Unknown provider: {provider}" });
225+
}
226+
227+
if (providerInfo.RequiresApiKey && string.IsNullOrWhiteSpace(apiKey))
228+
{
229+
return Ok(new ProviderTestResponse
230+
{
231+
Success = false,
232+
Provider = provider,
233+
Message = "No API key configured for this provider"
234+
});
235+
}
236+
237+
// Try to create the provider and make a test translation
238+
try
239+
{
240+
var translationProvider = TranslationProviderFactory.Create(provider.ToLowerInvariant(), config);
241+
242+
var testRequest = new TranslationRequest
243+
{
244+
SourceText = "Hello",
245+
SourceLanguage = "en",
246+
TargetLanguage = "es",
247+
Context = "test"
248+
};
249+
250+
var response = await translationProvider.TranslateAsync(testRequest);
251+
252+
return Ok(new ProviderTestResponse
253+
{
254+
Success = true,
255+
Provider = provider,
256+
Message = $"Connection successful! Test translation: 'Hello' -> '{response.TranslatedText}'"
257+
});
258+
}
259+
catch (Exception ex)
260+
{
261+
// Sanitize error message
262+
var message = ex.Message.Contains("API key") || ex.Message.Contains("authentication")
263+
? "Authentication failed - check your API key"
264+
: ex.Message.Contains("rate limit")
265+
? "Rate limit exceeded - try again later"
266+
: ex.Message.Contains("network") || ex is HttpRequestException
267+
? "Network error - check your connection"
268+
: "Provider test failed";
269+
270+
return Ok(new ProviderTestResponse
271+
{
272+
Success = false,
273+
Provider = provider,
274+
Message = message
275+
});
276+
}
277+
}
278+
catch (Exception)
279+
{
280+
return StatusCode(500, new ErrorResponse { Error = "An error occurred while processing your request" });
281+
}
282+
}
283+
284+
/// <summary>
285+
/// Enable or disable the secure credential store
286+
/// </summary>
287+
[HttpPut("secure-store")]
288+
public ActionResult<OperationResponse> SetSecureStoreEnabled([FromBody] SetSecureStoreRequest request)
289+
{
290+
try
291+
{
292+
var config = _configService.GetConfiguration() ?? new ConfigurationModel();
293+
if (config.Translation == null)
294+
{
295+
config.Translation = new TranslationConfiguration();
296+
}
297+
298+
config.Translation.UseSecureCredentialStore = request.Enabled;
299+
_configService.SaveConfiguration(config);
300+
301+
return Ok(new OperationResponse
302+
{
303+
Success = true,
304+
Message = request.Enabled
305+
? "Secure credential store enabled"
306+
: "Secure credential store disabled"
307+
});
308+
}
309+
catch (Exception)
310+
{
311+
return StatusCode(500, new ErrorResponse { Error = "An error occurred while processing your request" });
312+
}
313+
}
314+
}
315+
316+
// Request/Response models
317+
public class SetApiKeyRequest
318+
{
319+
public string ApiKey { get; set; } = string.Empty;
320+
}
321+
322+
public class SetSecureStoreRequest
323+
{
324+
public bool Enabled { get; set; }
325+
}
326+
327+
public class CredentialProviderInfo
328+
{
329+
public string Provider { get; set; } = string.Empty;
330+
public string DisplayName { get; set; } = string.Empty;
331+
public bool RequiresApiKey { get; set; }
332+
public string? Source { get; set; }
333+
public bool IsConfigured { get; set; }
334+
}
335+
336+
public class CredentialProvidersResponse
337+
{
338+
public List<CredentialProviderInfo> Providers { get; set; } = new();
339+
public bool UseSecureCredentialStore { get; set; }
340+
}
341+
342+
public class CredentialSourceResponse
343+
{
344+
public string Provider { get; set; } = string.Empty;
345+
public string? Source { get; set; }
346+
public bool IsConfigured { get; set; }
347+
}
348+
349+
public class ProviderTestResponse
350+
{
351+
public bool Success { get; set; }
352+
public string Provider { get; set; } = string.Empty;
353+
public string Message { get; set; } = string.Empty;
354+
}

0 commit comments

Comments
 (0)