Repository navigation
Expand file tree
/
Copy pathSettingsViewModel.cs
More file actions
304 lines (250 loc) · 11.1 KB
/
Copy pathSettingsViewModel.cs
File metadata and controls
304 lines (250 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MissionController.Core;
using MissionController.Core.Interfaces;
using MissionController.Infrastructure.Network.Ai;
namespace MissionController.UI.ViewModels;
public partial class SettingsViewModel : ObservableObject
{
private readonly ICredentialVault _vault;
private readonly ITokenLifecycleManager _lifecycleManager;
private readonly GeminiStreamClient _geminiClient;
private readonly ClaudeStreamClient _claudeClient;
[ObservableProperty]
private string _anthropicKeyStatus = "No Anthropic API key stored";
[ObservableProperty]
private string _geminiKeyStatus = "No Gemini API key stored";
[ObservableProperty]
private string _oAuthStatus = "No active OAuth token stored";
[ObservableProperty]
private string _saStatus = "No Service Account loaded";
[ObservableProperty]
private string _renewalMessage = "Auto-Renew Active (120s buffer threshold)";
[ObservableProperty]
private string _newApiKeyInput = string.Empty;
[ObservableProperty]
private bool _isSavingKey;
[ObservableProperty]
private string _keyValidationStatus = string.Empty;
[ObservableProperty]
private string _selectedLegalKey = "eula";
[ObservableProperty]
private string _selectedLegalTitle = SplatLegalTerms.EulaTitle;
[ObservableProperty]
private string _selectedLegalSummary = SplatLegalTerms.AllDocuments[0].Summary;
[ObservableProperty]
private string _selectedLegalText = SplatLegalTerms.EulaText;
public string LegalShortNotice => SplatLegalTerms.ShortNotice;
// Vault key names are surfaced as bindable properties so the Remove buttons pass the SAME
// identifier the readers use. Hardcoding these strings in XAML would let the delete path drift
// away from the store path, producing a button that reports success and removes nothing.
public const string OAuthVaultKeyName = "GoogleCloud.OAuthToken";
public const string ServiceAccountVaultKeyName = "GoogleCloud.ServiceAccountToken";
public string AnthropicVaultKey => ClaudeStreamClient.ApiKeyVaultKey;
public string GeminiVaultKey => GeminiStreamClient.ApiKeyVaultKey;
public string OAuthVaultKey => OAuthVaultKeyName;
public string ServiceAccountVaultKey => ServiceAccountVaultKeyName;
[ObservableProperty]
private bool _hasAnthropicKey;
[ObservableProperty]
private bool _hasGeminiKey;
[ObservableProperty]
private bool _hasOAuthToken;
[ObservableProperty]
private bool _hasServiceAccountToken;
public SettingsViewModel(
ICredentialVault vault,
ITokenLifecycleManager lifecycleManager,
GeminiStreamClient geminiClient,
ClaudeStreamClient claudeClient)
{
_vault = vault;
_lifecycleManager = lifecycleManager;
_geminiClient = geminiClient;
_claudeClient = claudeClient;
}
[RelayCommand]
public void SelectLegalDocument(string? key)
{
string normalized = string.IsNullOrWhiteSpace(key) ? "eula" : key.Trim().ToLowerInvariant();
if (string.Equals(normalized, "all", StringComparison.OrdinalIgnoreCase))
{
SelectedLegalKey = "all";
SelectedLegalTitle = "PLATTEN INDUSTRIES LLC — Complete Legal Agreement Bundle";
SelectedLegalSummary = "Community AGPL license, warranty notice, operating guidance and privacy policy. No additional EULA acceptance is required.";
SelectedLegalText = SplatLegalTerms.FormatAll();
return;
}
var doc = SplatLegalTerms.TryGetByKeyOrUri(normalized) ?? SplatLegalTerms.AllDocuments[0];
SelectedLegalKey = doc.Key;
SelectedLegalTitle = doc.Title;
SelectedLegalSummary = doc.Summary;
SelectedLegalText = doc.FullText;
}
private System.Threading.CancellationTokenSource? _autoValidationCts;
partial void OnNewApiKeyInputChanged(string value)
{
_autoValidationCts?.Cancel();
_autoValidationCts?.Dispose();
_autoValidationCts = null;
if (string.IsNullOrWhiteSpace(value))
{
KeyValidationStatus = string.Empty;
return;
}
string trimmed = value.Trim();
if (trimmed.Length < 10)
{
KeyValidationStatus = "Enter full API key...";
return;
}
_autoValidationCts = new System.Threading.CancellationTokenSource();
var token = _autoValidationCts.Token;
_ = System.Threading.Tasks.Task.Run(async () =>
{
try
{
bool isFastCandidate = (trimmed.StartsWith("sk-ant-", System.StringComparison.Ordinal) && trimmed.Length >= 40)
|| (trimmed.StartsWith("AIza", System.StringComparison.Ordinal) && trimmed.Length >= 30);
if (!isFastCandidate)
{
await System.Threading.Tasks.Task.Delay(350, token);
}
if (token.IsCancellationRequested) return;
await SaveAndValidateInternalAsync(trimmed, token);
}
catch (System.OperationCanceledException)
{
// Expected on fast typing
}
}, token);
}
[RelayCommand]
public async System.Threading.Tasks.Task SaveAndValidateKeyAsync()
{
if (string.IsNullOrWhiteSpace(NewApiKeyInput))
{
KeyValidationStatus = "Please enter an API Key to verify and save.";
return;
}
await SaveAndValidateInternalAsync(NewApiKeyInput.Trim(), System.Threading.CancellationToken.None);
}
private async System.Threading.Tasks.Task SaveAndValidateInternalAsync(string key, System.Threading.CancellationToken cancellationToken)
{
var (vaultKey, providerName) = ProviderKeyClassifier.Classify(key);
IsSavingKey = true;
try
{
KeyValidationStatus = $"Validating {providerName} API key against live endpoint...";
Core.Models.AiGatewayValidationResult validation;
if (vaultKey == GeminiStreamClient.ApiKeyVaultKey)
{
validation = await _geminiClient.ValidateApiKeyAsync(key, cancellationToken);
}
else
{
validation = await _claudeClient.ValidateApiKeyAsync(key, cancellationToken);
}
if (cancellationToken.IsCancellationRequested) return;
if (!validation.IsValid)
{
KeyValidationStatus = $"🔴 Key rejected: {validation.Message}";
return;
}
await _vault.StoreSecretStringAsync(vaultKey, key, cancellationToken);
KeyValidationStatus = $"⚡ {providerName} key verified live and stored in DPAPI vault.";
await CheckVaultStatusAsync();
}
catch (System.OperationCanceledException)
{
// Ignored
}
catch (System.Exception ex)
{
KeyValidationStatus = $"🔴 Error saving key: {ex.Message}";
}
finally
{
IsSavingKey = false;
}
}
/// <summary>
/// Deletes one stored secret from the DPAPI vault. The provider clients read the vault on every
/// request and cache nothing, so removal takes effect on the very next mission with no restart.
/// </summary>
[RelayCommand]
public async System.Threading.Tasks.Task RemoveStoredSecretAsync(string? vaultKey)
{
if (string.IsNullOrWhiteSpace(vaultKey))
{
return;
}
try
{
// A debounced auto-validation from earlier typing would happily re-store the key we are
// about to delete, so cancel it before touching the vault.
_autoValidationCts?.Cancel();
await _vault.DeleteSecretAsync(vaultKey);
// Clear the input FIRST: assigning it fires OnNewApiKeyInputChanged, which blanks
// KeyValidationStatus. Setting the confirmation afterwards is what makes it survive.
NewApiKeyInput = string.Empty;
KeyValidationStatus = $"🗑 Removed {DescribeVaultKey(vaultKey)} from the DPAPI vault.";
await CheckVaultStatusAsync();
}
catch (System.Exception ex)
{
KeyValidationStatus = $"🔴 Could not remove credential: {ex.Message}";
}
}
private string DescribeVaultKey(string vaultKey) => vaultKey switch
{
_ when vaultKey == ClaudeStreamClient.ApiKeyVaultKey => "the Anthropic Claude API key",
_ when vaultKey == GeminiStreamClient.ApiKeyVaultKey => "the Google Gemini API key",
OAuthVaultKeyName => "the OAuth 2.0 token",
ServiceAccountVaultKeyName => "the Service Account token",
_ => $"'{vaultKey}'"
};
[RelayCommand]
public async System.Threading.Tasks.Task CheckVaultStatusAsync()
{
// Provider API keys are the credentials the mission AI path actually consumes; the vault
// key names come from the client constants so Settings can never drift from the readers.
string? anthropicKey = await _vault.RetrieveSecretStringAsync(ClaudeStreamClient.ApiKeyVaultKey);
HasAnthropicKey = !string.IsNullOrEmpty(anthropicKey);
AnthropicKeyStatus = HasAnthropicKey
? $"Active Anthropic API key ({MaskKey(anthropicKey!)}) — default provider"
: "No Anthropic API key in DPAPI Vault";
string? geminiKey = await _vault.RetrieveSecretStringAsync(GeminiStreamClient.ApiKeyVaultKey);
HasGeminiKey = !string.IsNullOrEmpty(geminiKey);
GeminiKeyStatus = HasGeminiKey
? $"Active Gemini API key ({MaskKey(geminiKey!)})"
: "No Gemini API key in DPAPI Vault";
string? oauthToken = await _vault.RetrieveSecretStringAsync(OAuthVaultKeyName);
HasOAuthToken = !string.IsNullOrEmpty(oauthToken);
OAuthStatus = HasOAuthToken
? $"Active OAuth Bearer Token ({MaskKey(oauthToken!)})"
: "No OAuth Token in DPAPI Vault";
string? saToken = await _vault.RetrieveSecretStringAsync(ServiceAccountVaultKeyName);
HasServiceAccountToken = !string.IsNullOrEmpty(saToken);
SaStatus = HasServiceAccountToken
? $"Active Service Account Token ({MaskKey(saToken!)})"
: "No Service Account Token in DPAPI Vault";
}
[RelayCommand]
public async System.Threading.Tasks.Task ForceTokenRefreshAsync()
{
RenewalMessage = "Triggering proactive token refresh...";
var res = await _lifecycleManager.RefreshTokenAsync("GoogleCloud.OAuthToken");
if (!res.Success)
{
res = await _lifecycleManager.RefreshTokenAsync("GoogleCloud.ServiceAccountToken");
}
RenewalMessage = res.Success
? $"SUCCESS: Token renewed at {DateTime.UtcNow:T} UTC (Expires {res.Token?.ExpiresAtUtc:t})"
: $"Notice: {res.ErrorMessage ?? "No refresh handler registered"}";
await CheckVaultStatusAsync();
}
private static string MaskKey(string key) =>
key.Length <= 8 ? "••••" : $"{key.Substring(0, 8)}…";
}