@auxiora/dashboard 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +191 -0
- package/dist/auth.d.ts +13 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +69 -0
- package/dist/auth.js.map +1 -0
- package/dist/cloud-types.d.ts +71 -0
- package/dist/cloud-types.d.ts.map +1 -0
- package/dist/cloud-types.js +2 -0
- package/dist/cloud-types.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/router.d.ts +13 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +2250 -0
- package/dist/router.js.map +1 -0
- package/dist/types.d.ts +314 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/dist-ui/assets/index-BfY0i5jw.css +1 -0
- package/dist-ui/assets/index-CXpk9mvw.js +60 -0
- package/dist-ui/icon.svg +59 -0
- package/dist-ui/index.html +20 -0
- package/package.json +32 -0
- package/src/auth.ts +83 -0
- package/src/cloud-types.ts +63 -0
- package/src/index.ts +5 -0
- package/src/router.ts +2494 -0
- package/src/types.ts +269 -0
- package/tests/auth.test.ts +51 -0
- package/tests/cloud-router.test.ts +249 -0
- package/tests/desktop-router.test.ts +151 -0
- package/tests/router.test.ts +388 -0
- package/tests/trust-router.test.ts +170 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/ui/index.html +19 -0
- package/ui/node_modules/.bin/browserslist +17 -0
- package/ui/node_modules/.bin/tsc +17 -0
- package/ui/node_modules/.bin/tsserver +17 -0
- package/ui/node_modules/.bin/vite +17 -0
- package/ui/package.json +23 -0
- package/ui/public/icon.svg +59 -0
- package/ui/src/App.tsx +63 -0
- package/ui/src/api.ts +238 -0
- package/ui/src/components/ActivityFeed.tsx +123 -0
- package/ui/src/components/BehaviorHealth.tsx +105 -0
- package/ui/src/components/DataTable.tsx +39 -0
- package/ui/src/components/Layout.tsx +160 -0
- package/ui/src/components/PasswordStrength.tsx +31 -0
- package/ui/src/components/SetupProgress.tsx +26 -0
- package/ui/src/components/StatusBadge.tsx +12 -0
- package/ui/src/components/ThemeSelector.tsx +39 -0
- package/ui/src/contexts/ThemeContext.tsx +58 -0
- package/ui/src/hooks/useApi.ts +19 -0
- package/ui/src/hooks/usePolling.ts +8 -0
- package/ui/src/main.tsx +16 -0
- package/ui/src/pages/AuditLog.tsx +36 -0
- package/ui/src/pages/Behaviors.tsx +426 -0
- package/ui/src/pages/Chat.tsx +688 -0
- package/ui/src/pages/Login.tsx +64 -0
- package/ui/src/pages/Overview.tsx +56 -0
- package/ui/src/pages/Sessions.tsx +26 -0
- package/ui/src/pages/SettingsAmbient.tsx +185 -0
- package/ui/src/pages/SettingsConnections.tsx +201 -0
- package/ui/src/pages/SettingsNotifications.tsx +241 -0
- package/ui/src/pages/SetupAppearance.tsx +45 -0
- package/ui/src/pages/SetupChannels.tsx +143 -0
- package/ui/src/pages/SetupComplete.tsx +31 -0
- package/ui/src/pages/SetupConnections.tsx +80 -0
- package/ui/src/pages/SetupDashboardPassword.tsx +50 -0
- package/ui/src/pages/SetupIdentity.tsx +68 -0
- package/ui/src/pages/SetupPersonality.tsx +78 -0
- package/ui/src/pages/SetupProvider.tsx +65 -0
- package/ui/src/pages/SetupVault.tsx +50 -0
- package/ui/src/pages/SetupWelcome.tsx +19 -0
- package/ui/src/pages/UnlockVault.tsx +56 -0
- package/ui/src/pages/Webhooks.tsx +158 -0
- package/ui/src/pages/settings/Appearance.tsx +63 -0
- package/ui/src/pages/settings/Channels.tsx +138 -0
- package/ui/src/pages/settings/Identity.tsx +61 -0
- package/ui/src/pages/settings/Personality.tsx +54 -0
- package/ui/src/pages/settings/PersonalityEditor.tsx +577 -0
- package/ui/src/pages/settings/Provider.tsx +537 -0
- package/ui/src/pages/settings/Security.tsx +111 -0
- package/ui/src/styles/global.css +2308 -0
- package/ui/src/styles/themes/index.css +7 -0
- package/ui/src/styles/themes/monolith.css +125 -0
- package/ui/src/styles/themes/nebula.css +90 -0
- package/ui/src/styles/themes/neon.css +149 -0
- package/ui/src/styles/themes/polar.css +151 -0
- package/ui/src/styles/themes/signal.css +163 -0
- package/ui/src/styles/themes/terra.css +146 -0
- package/ui/tsconfig.json +14 -0
- package/ui/vite.config.ts +20 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useApi } from '../../hooks/useApi';
|
|
3
|
+
import { api } from '../../api';
|
|
4
|
+
|
|
5
|
+
interface ProviderInfo {
|
|
6
|
+
name: string;
|
|
7
|
+
displayName: string;
|
|
8
|
+
available: boolean;
|
|
9
|
+
models: Record<string, unknown>;
|
|
10
|
+
credentialSource?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface RoutingInfo {
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
primary: string;
|
|
16
|
+
fallback?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CostInfo {
|
|
20
|
+
today: number;
|
|
21
|
+
thisMonth: number;
|
|
22
|
+
isOverBudget: boolean;
|
|
23
|
+
warningThresholdReached: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const KNOWN_PROVIDERS: Array<{
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
needsKey: boolean;
|
|
30
|
+
needsEndpoint?: boolean;
|
|
31
|
+
needsOAuth?: boolean;
|
|
32
|
+
}> = [
|
|
33
|
+
{ id: 'anthropic', label: 'Anthropic (Claude)', needsKey: true },
|
|
34
|
+
{ id: 'claudeOAuth', label: 'Claude (OAuth)', needsKey: false, needsOAuth: true },
|
|
35
|
+
{ id: 'openai', label: 'OpenAI', needsKey: true },
|
|
36
|
+
{ id: 'google', label: 'Google (Gemini)', needsKey: true },
|
|
37
|
+
{ id: 'ollama', label: 'Ollama (Local)', needsKey: false, needsEndpoint: true },
|
|
38
|
+
{ id: 'groq', label: 'Groq', needsKey: true },
|
|
39
|
+
{ id: 'deepseek', label: 'DeepSeek', needsKey: true },
|
|
40
|
+
{ id: 'cohere', label: 'Cohere', needsKey: true },
|
|
41
|
+
{ id: 'xai', label: 'xAI (Grok)', needsKey: true },
|
|
42
|
+
{ id: 'openaiCompatible', label: 'OpenAI-Compatible', needsKey: true, needsEndpoint: true },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** Turn a raw model ID into a friendly display name */
|
|
46
|
+
function friendlyModelName(id: string): string {
|
|
47
|
+
// Anthropic — order matters: more specific prefixes first
|
|
48
|
+
if (id.startsWith('claude-opus-4-6')) return 'Claude Opus 4.6';
|
|
49
|
+
if (id.startsWith('claude-sonnet-4-5')) return 'Claude Sonnet 4.5';
|
|
50
|
+
if (id.startsWith('claude-haiku-4-5')) return 'Claude Haiku 4.5';
|
|
51
|
+
if (id.startsWith('claude-opus-4')) return 'Claude Opus 4';
|
|
52
|
+
if (id.startsWith('claude-sonnet-4')) return 'Claude Sonnet 4';
|
|
53
|
+
if (id.startsWith('claude-3-5-haiku')) return 'Claude Haiku 3.5';
|
|
54
|
+
if (id.startsWith('claude-3-5-sonnet')) return 'Claude Sonnet 3.5';
|
|
55
|
+
if (id.startsWith('claude-3-opus')) return 'Claude Opus 3';
|
|
56
|
+
// OpenAI
|
|
57
|
+
if (id === 'gpt-4o') return 'GPT-4o';
|
|
58
|
+
if (id === 'gpt-4o-mini') return 'GPT-4o Mini';
|
|
59
|
+
if (id === 'gpt-4-turbo') return 'GPT-4 Turbo';
|
|
60
|
+
if (id.startsWith('o1')) return id.toUpperCase();
|
|
61
|
+
if (id.startsWith('o3')) return id.toUpperCase();
|
|
62
|
+
// Google
|
|
63
|
+
if (id.startsWith('gemini-'))return id.replace('gemini-', 'Gemini ');
|
|
64
|
+
// Fallback: just show the ID
|
|
65
|
+
return id;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function SettingsProvider() {
|
|
69
|
+
const { data, refresh } = useApi(() => api.getModels(), []);
|
|
70
|
+
const [expanded, setExpanded] = useState<string | null>(null);
|
|
71
|
+
const [saving, setSaving] = useState(false);
|
|
72
|
+
const [success, setSuccess] = useState('');
|
|
73
|
+
const [error, setError] = useState('');
|
|
74
|
+
|
|
75
|
+
// Claude OAuth flow state
|
|
76
|
+
const [oauthConnecting, setOauthConnecting] = useState(false);
|
|
77
|
+
const [oauthCode, setOauthCode] = useState('');
|
|
78
|
+
const [oauthWaitingForCode, setOauthWaitingForCode] = useState(false);
|
|
79
|
+
const [oauthConnected, setOauthConnected] = useState(false);
|
|
80
|
+
|
|
81
|
+
// Routing state
|
|
82
|
+
const [primary, setPrimary] = useState('');
|
|
83
|
+
const [fallback, setFallback] = useState('');
|
|
84
|
+
const [routingSaving, setRoutingSaving] = useState(false);
|
|
85
|
+
|
|
86
|
+
// Per-card form state
|
|
87
|
+
const [cardApiKey, setCardApiKey] = useState('');
|
|
88
|
+
const [cardEndpoint, setCardEndpoint] = useState('');
|
|
89
|
+
const [cardModel, setCardModel] = useState('');
|
|
90
|
+
|
|
91
|
+
// Active model change state
|
|
92
|
+
const [activeModelSaving, setActiveModelSaving] = useState(false);
|
|
93
|
+
|
|
94
|
+
const providers: ProviderInfo[] = data?.providers ?? [];
|
|
95
|
+
const routing: RoutingInfo = data?.routing ?? { enabled: false, primary: '', fallback: '' };
|
|
96
|
+
const cost: CostInfo = data?.cost ?? { today: 0, thisMonth: 0, isOverBudget: false, warningThresholdReached: false };
|
|
97
|
+
|
|
98
|
+
// Sync routing state when data loads
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (routing.primary) setPrimary(routing.primary);
|
|
101
|
+
if (routing.fallback) setFallback(routing.fallback);
|
|
102
|
+
}, [routing.primary, routing.fallback]);
|
|
103
|
+
|
|
104
|
+
// Check Claude OAuth status on load
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
api.getClaudeOAuthStatus()
|
|
107
|
+
.then(s => setOauthConnected(s.connected))
|
|
108
|
+
.catch(err => console.error('Failed to check OAuth status:', err));
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
const configuredNames = new Set(providers.filter(p => p.available).map(p => p.name));
|
|
112
|
+
|
|
113
|
+
const getProviderModels = (name: string): string[] => {
|
|
114
|
+
const p = providers.find(pr => pr.name === name);
|
|
115
|
+
if (!p?.models) return [];
|
|
116
|
+
return Object.keys(p.models);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Build a flat list of all available models across all providers
|
|
120
|
+
const allModels: Array<{ provider: string; providerLabel: string; model: string }> = [];
|
|
121
|
+
for (const p of providers) {
|
|
122
|
+
if (!p.available || !p.models) continue;
|
|
123
|
+
const spec = KNOWN_PROVIDERS.find(k => k.id === p.name);
|
|
124
|
+
for (const modelId of Object.keys(p.models)) {
|
|
125
|
+
allModels.push({
|
|
126
|
+
provider: p.name,
|
|
127
|
+
providerLabel: spec?.label ?? p.displayName ?? p.name,
|
|
128
|
+
model: modelId,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const handleExpand = (id: string) => {
|
|
134
|
+
if (expanded === id) {
|
|
135
|
+
setExpanded(null);
|
|
136
|
+
} else {
|
|
137
|
+
setExpanded(id);
|
|
138
|
+
setCardApiKey('');
|
|
139
|
+
setCardEndpoint('');
|
|
140
|
+
setCardModel('');
|
|
141
|
+
setError('');
|
|
142
|
+
setSuccess('');
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const handleSaveProvider = async (providerId: string) => {
|
|
147
|
+
setSaving(true);
|
|
148
|
+
setError('');
|
|
149
|
+
setSuccess('');
|
|
150
|
+
try {
|
|
151
|
+
const spec = KNOWN_PROVIDERS.find(p => p.id === providerId);
|
|
152
|
+
await api.configureProvider(
|
|
153
|
+
providerId,
|
|
154
|
+
spec?.needsKey ? cardApiKey || undefined : undefined,
|
|
155
|
+
spec?.needsEndpoint ? cardEndpoint || undefined : undefined,
|
|
156
|
+
);
|
|
157
|
+
if (cardModel) {
|
|
158
|
+
await api.setProviderModel(providerId, cardModel);
|
|
159
|
+
}
|
|
160
|
+
setSuccess(`${spec?.label ?? providerId} configured successfully`);
|
|
161
|
+
setCardApiKey('');
|
|
162
|
+
await refresh();
|
|
163
|
+
} catch (err: any) {
|
|
164
|
+
setError(err.message);
|
|
165
|
+
} finally {
|
|
166
|
+
setSaving(false);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const handleStartOAuth = async () => {
|
|
171
|
+
setOauthConnecting(true);
|
|
172
|
+
setError('');
|
|
173
|
+
setSuccess('');
|
|
174
|
+
try {
|
|
175
|
+
const { authUrl } = await api.startClaudeOAuth();
|
|
176
|
+
window.open(authUrl, '_blank');
|
|
177
|
+
setOauthWaitingForCode(true);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
setError(err instanceof Error ? err.message : 'Failed to start OAuth');
|
|
180
|
+
} finally {
|
|
181
|
+
setOauthConnecting(false);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const handleCompleteOAuth = async () => {
|
|
186
|
+
if (!oauthCode.trim()) return;
|
|
187
|
+
setSaving(true);
|
|
188
|
+
setError('');
|
|
189
|
+
setSuccess('');
|
|
190
|
+
try {
|
|
191
|
+
await api.completeClaudeOAuth(oauthCode.trim());
|
|
192
|
+
setSuccess('Claude OAuth connected successfully');
|
|
193
|
+
setOauthWaitingForCode(false);
|
|
194
|
+
setOauthCode('');
|
|
195
|
+
setOauthConnected(true);
|
|
196
|
+
await refresh();
|
|
197
|
+
} catch (err) {
|
|
198
|
+
setError(err instanceof Error ? err.message : 'Failed to complete OAuth');
|
|
199
|
+
} finally {
|
|
200
|
+
setSaving(false);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const handleDisconnectOAuth = async () => {
|
|
205
|
+
setSaving(true);
|
|
206
|
+
setError('');
|
|
207
|
+
setSuccess('');
|
|
208
|
+
try {
|
|
209
|
+
await api.disconnectClaudeOAuth();
|
|
210
|
+
setSuccess('Claude OAuth disconnected');
|
|
211
|
+
setOauthConnected(false);
|
|
212
|
+
await refresh();
|
|
213
|
+
} catch (err) {
|
|
214
|
+
setError(err instanceof Error ? err.message : 'Failed to disconnect OAuth');
|
|
215
|
+
} finally {
|
|
216
|
+
setSaving(false);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const handleSaveRouting = async () => {
|
|
221
|
+
setRoutingSaving(true);
|
|
222
|
+
setError('');
|
|
223
|
+
setSuccess('');
|
|
224
|
+
try {
|
|
225
|
+
await api.updateRouting(primary, fallback || undefined);
|
|
226
|
+
setSuccess('Routing updated');
|
|
227
|
+
await refresh();
|
|
228
|
+
} catch (err: any) {
|
|
229
|
+
setError(err.message);
|
|
230
|
+
} finally {
|
|
231
|
+
setRoutingSaving(false);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const handleActiveModelChange = async (value: string) => {
|
|
236
|
+
if (!value) return;
|
|
237
|
+
const [provider, ...rest] = value.split('/');
|
|
238
|
+
const model = rest.join('/');
|
|
239
|
+
setActiveModelSaving(true);
|
|
240
|
+
setError('');
|
|
241
|
+
setSuccess('');
|
|
242
|
+
try {
|
|
243
|
+
// Set this as the default model for the provider
|
|
244
|
+
await api.setProviderModel(provider, model);
|
|
245
|
+
// If a different provider is selected, also make it primary
|
|
246
|
+
if (provider !== routing.primary) {
|
|
247
|
+
await api.updateRouting(provider, routing.primary || undefined);
|
|
248
|
+
}
|
|
249
|
+
setSuccess(`Switched to ${friendlyModelName(model)}`);
|
|
250
|
+
await refresh();
|
|
251
|
+
} catch (err: any) {
|
|
252
|
+
setError(err.message);
|
|
253
|
+
} finally {
|
|
254
|
+
setActiveModelSaving(false);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const configuredProviderOptions = providers.filter(p => p.available);
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div className="page">
|
|
262
|
+
<h2>Providers</h2>
|
|
263
|
+
|
|
264
|
+
{/* Active Model Selector — the main thing people want */}
|
|
265
|
+
{allModels.length > 0 && (
|
|
266
|
+
<div className="settings-section">
|
|
267
|
+
<h3>Active Model</h3>
|
|
268
|
+
<p style={{ color: 'var(--text-secondary)', fontSize: '0.85rem', marginBottom: '0.75rem' }}>
|
|
269
|
+
Choose which AI model to use. This changes the default for all new messages.
|
|
270
|
+
</p>
|
|
271
|
+
<div className="routing-config">
|
|
272
|
+
<div className="routing-field" style={{ flex: 2 }}>
|
|
273
|
+
<label>Model</label>
|
|
274
|
+
<select
|
|
275
|
+
onChange={e => handleActiveModelChange(e.target.value)}
|
|
276
|
+
disabled={activeModelSaving}
|
|
277
|
+
defaultValue=""
|
|
278
|
+
>
|
|
279
|
+
<option value="" disabled>Select a model...</option>
|
|
280
|
+
{configuredProviderOptions.map(p => {
|
|
281
|
+
const models = getProviderModels(p.name);
|
|
282
|
+
if (models.length === 0) return null;
|
|
283
|
+
const spec = KNOWN_PROVIDERS.find(k => k.id === p.name);
|
|
284
|
+
return (
|
|
285
|
+
<optgroup key={p.name} label={spec?.label ?? p.displayName ?? p.name}>
|
|
286
|
+
{models.map(m => (
|
|
287
|
+
<option key={`${p.name}/${m}`} value={`${p.name}/${m}`}>
|
|
288
|
+
{friendlyModelName(m)}
|
|
289
|
+
</option>
|
|
290
|
+
))}
|
|
291
|
+
</optgroup>
|
|
292
|
+
);
|
|
293
|
+
})}
|
|
294
|
+
</select>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
{success && <div className="settings-success">{success}</div>}
|
|
298
|
+
{error && <div className="error">{error}</div>}
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
|
|
302
|
+
{/* Routing Configuration */}
|
|
303
|
+
{configuredProviderOptions.length > 1 && (
|
|
304
|
+
<div className="settings-section">
|
|
305
|
+
<h3>Provider Routing</h3>
|
|
306
|
+
<p style={{ color: 'var(--text-secondary)', fontSize: '0.85rem', marginBottom: '0.75rem' }}>
|
|
307
|
+
Set primary and fallback providers. Fallback is used when the primary is unavailable.
|
|
308
|
+
</p>
|
|
309
|
+
<div className="routing-config">
|
|
310
|
+
<div className="routing-field">
|
|
311
|
+
<label>Primary Provider</label>
|
|
312
|
+
<select value={primary} onChange={e => setPrimary(e.target.value)}>
|
|
313
|
+
<option value="">Select...</option>
|
|
314
|
+
{configuredProviderOptions.map(p => (
|
|
315
|
+
<option key={p.name} value={p.name}>{p.displayName || p.name}</option>
|
|
316
|
+
))}
|
|
317
|
+
</select>
|
|
318
|
+
</div>
|
|
319
|
+
<div className="routing-field">
|
|
320
|
+
<label>Fallback Provider</label>
|
|
321
|
+
<select value={fallback} onChange={e => setFallback(e.target.value)}>
|
|
322
|
+
<option value="">None</option>
|
|
323
|
+
{configuredProviderOptions.filter(p => p.name !== primary).map(p => (
|
|
324
|
+
<option key={p.name} value={p.name}>{p.displayName || p.name}</option>
|
|
325
|
+
))}
|
|
326
|
+
</select>
|
|
327
|
+
</div>
|
|
328
|
+
<button onClick={handleSaveRouting} disabled={routingSaving || !primary}>
|
|
329
|
+
{routingSaving ? 'Saving...' : 'Save Routing'}
|
|
330
|
+
</button>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
|
|
335
|
+
{/* Cost Summary */}
|
|
336
|
+
<div className="settings-section">
|
|
337
|
+
<h3>Usage</h3>
|
|
338
|
+
<div className="cost-summary">
|
|
339
|
+
<div className={`cost-box${cost.isOverBudget ? ' over-budget' : ''}`}>
|
|
340
|
+
<h4>Today</h4>
|
|
341
|
+
<div className="cost-value">${cost.today.toFixed(2)}</div>
|
|
342
|
+
</div>
|
|
343
|
+
<div className={`cost-box${cost.isOverBudget ? ' over-budget' : ''}`}>
|
|
344
|
+
<h4>This Month</h4>
|
|
345
|
+
<div className="cost-value">${cost.thisMonth.toFixed(2)}</div>
|
|
346
|
+
</div>
|
|
347
|
+
<div className="cost-box">
|
|
348
|
+
<h4>Budget Status</h4>
|
|
349
|
+
<div className="cost-value" style={{ fontSize: '0.95rem' }}>
|
|
350
|
+
{cost.isOverBudget ? 'Over Budget' : cost.warningThresholdReached ? 'Warning' : 'OK'}
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
{/* Provider Cards */}
|
|
357
|
+
<div className="settings-section">
|
|
358
|
+
<h3>Manage Providers</h3>
|
|
359
|
+
<p style={{ color: 'var(--text-secondary)', fontSize: '0.85rem', marginBottom: '0.75rem' }}>
|
|
360
|
+
Click a card to configure API keys and endpoints.
|
|
361
|
+
</p>
|
|
362
|
+
<div className="provider-grid">
|
|
363
|
+
{KNOWN_PROVIDERS.map(spec => {
|
|
364
|
+
const providerData = providers.find(p => p.name === spec.id);
|
|
365
|
+
const isConfigured = configuredNames.has(spec.id);
|
|
366
|
+
const isOAuthCard = spec.id === 'claudeOAuth';
|
|
367
|
+
const isActive = isOAuthCard ? oauthConnected : isConfigured;
|
|
368
|
+
const isPrimary = routing.primary === spec.id;
|
|
369
|
+
const isFallback = routing.fallback === spec.id;
|
|
370
|
+
const isExpanded = expanded === spec.id;
|
|
371
|
+
const models = getProviderModels(spec.id);
|
|
372
|
+
|
|
373
|
+
return (
|
|
374
|
+
<div
|
|
375
|
+
key={spec.id}
|
|
376
|
+
className={`provider-card${isActive ? ' configured' : ''}${isExpanded ? ' expanded' : ''}`}
|
|
377
|
+
onClick={() => handleExpand(spec.id)}
|
|
378
|
+
>
|
|
379
|
+
<div className="provider-card-header">
|
|
380
|
+
<h3>
|
|
381
|
+
<span className={`status-dot ${isActive ? 'active' : 'inactive'}`} />
|
|
382
|
+
{isActive && providerData?.displayName ? providerData.displayName : spec.label}
|
|
383
|
+
</h3>
|
|
384
|
+
<div className="provider-badges">
|
|
385
|
+
{isPrimary && <span className="badge-pill badge-primary">Primary</span>}
|
|
386
|
+
{isFallback && <span className="badge-pill badge-fallback">Fallback</span>}
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
<div className="provider-model">
|
|
390
|
+
{isActive
|
|
391
|
+
? (models.length > 0
|
|
392
|
+
? models.map(m => friendlyModelName(m)).join(', ')
|
|
393
|
+
: 'Active')
|
|
394
|
+
: 'Not configured'}
|
|
395
|
+
{providerData?.credentialSource === 'claude-cli' && (
|
|
396
|
+
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: 'var(--accent)' }}>(Claude Code CLI)</span>
|
|
397
|
+
)}
|
|
398
|
+
{providerData?.credentialSource === 'oauth' && (
|
|
399
|
+
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: 'var(--accent)' }}>(OAuth)</span>
|
|
400
|
+
)}
|
|
401
|
+
</div>
|
|
402
|
+
|
|
403
|
+
{isExpanded && (
|
|
404
|
+
<div className="provider-expand" onClick={e => e.stopPropagation()}>
|
|
405
|
+
{spec.needsOAuth ? (
|
|
406
|
+
// Claude OAuth flow
|
|
407
|
+
<>
|
|
408
|
+
{oauthConnected ? (
|
|
409
|
+
<>
|
|
410
|
+
<p style={{ color: 'var(--text-secondary)', fontSize: '0.85rem', marginBottom: '0.75rem' }}>
|
|
411
|
+
Connected via Claude OAuth. Your Claude Pro/Max subscription is being used.
|
|
412
|
+
</p>
|
|
413
|
+
{models.length > 0 && (
|
|
414
|
+
<>
|
|
415
|
+
<label>Default Model</label>
|
|
416
|
+
<select value={cardModel} onChange={e => setCardModel(e.target.value)}>
|
|
417
|
+
<option value="">Keep current</option>
|
|
418
|
+
{models.map(m => (
|
|
419
|
+
<option key={m} value={m}>{friendlyModelName(m)}</option>
|
|
420
|
+
))}
|
|
421
|
+
</select>
|
|
422
|
+
</>
|
|
423
|
+
)}
|
|
424
|
+
<div className="provider-actions">
|
|
425
|
+
<button
|
|
426
|
+
className="btn-save"
|
|
427
|
+
onClick={handleDisconnectOAuth}
|
|
428
|
+
disabled={saving}
|
|
429
|
+
style={{ background: 'var(--error, #e74c3c)' }}
|
|
430
|
+
>
|
|
431
|
+
{saving ? 'Disconnecting...' : 'Disconnect'}
|
|
432
|
+
</button>
|
|
433
|
+
</div>
|
|
434
|
+
</>
|
|
435
|
+
) : oauthWaitingForCode ? (
|
|
436
|
+
<>
|
|
437
|
+
<p style={{ color: 'var(--text-secondary)', fontSize: '0.85rem', marginBottom: '0.75rem' }}>
|
|
438
|
+
Authorize in the browser tab that opened, then paste the code below.
|
|
439
|
+
</p>
|
|
440
|
+
<label>Authorization Code</label>
|
|
441
|
+
<input
|
|
442
|
+
type="text"
|
|
443
|
+
value={oauthCode}
|
|
444
|
+
onChange={e => setOauthCode(e.target.value)}
|
|
445
|
+
placeholder="Paste the code from claude.ai"
|
|
446
|
+
autoFocus
|
|
447
|
+
/>
|
|
448
|
+
<div className="provider-actions">
|
|
449
|
+
<button
|
|
450
|
+
className="btn-save"
|
|
451
|
+
onClick={handleCompleteOAuth}
|
|
452
|
+
disabled={saving || !oauthCode.trim()}
|
|
453
|
+
>
|
|
454
|
+
{saving ? 'Connecting...' : 'Complete Connection'}
|
|
455
|
+
</button>
|
|
456
|
+
<button
|
|
457
|
+
onClick={() => { setOauthWaitingForCode(false); setOauthCode(''); }}
|
|
458
|
+
style={{ marginLeft: '0.5rem' }}
|
|
459
|
+
>
|
|
460
|
+
Cancel
|
|
461
|
+
</button>
|
|
462
|
+
</div>
|
|
463
|
+
</>
|
|
464
|
+
) : (
|
|
465
|
+
<>
|
|
466
|
+
<p style={{ color: 'var(--text-secondary)', fontSize: '0.85rem', marginBottom: '0.75rem' }}>
|
|
467
|
+
Connect your Claude Pro or Max subscription. No API key needed.
|
|
468
|
+
</p>
|
|
469
|
+
<div className="provider-actions">
|
|
470
|
+
<button
|
|
471
|
+
className="btn-save"
|
|
472
|
+
onClick={handleStartOAuth}
|
|
473
|
+
disabled={oauthConnecting}
|
|
474
|
+
>
|
|
475
|
+
{oauthConnecting ? 'Opening...' : 'Connect with Claude'}
|
|
476
|
+
</button>
|
|
477
|
+
</div>
|
|
478
|
+
</>
|
|
479
|
+
)}
|
|
480
|
+
</>
|
|
481
|
+
) : (
|
|
482
|
+
// Original API key / endpoint form
|
|
483
|
+
<>
|
|
484
|
+
{spec.needsKey && (
|
|
485
|
+
<>
|
|
486
|
+
<label>API Key</label>
|
|
487
|
+
<input
|
|
488
|
+
type="password"
|
|
489
|
+
value={cardApiKey}
|
|
490
|
+
onChange={e => setCardApiKey(e.target.value)}
|
|
491
|
+
placeholder={isConfigured ? '•••••••• (leave blank to keep)' : 'Enter API key'}
|
|
492
|
+
/>
|
|
493
|
+
</>
|
|
494
|
+
)}
|
|
495
|
+
{spec.needsEndpoint && (
|
|
496
|
+
<>
|
|
497
|
+
<label>Endpoint URL</label>
|
|
498
|
+
<input
|
|
499
|
+
type="text"
|
|
500
|
+
value={cardEndpoint}
|
|
501
|
+
onChange={e => setCardEndpoint(e.target.value)}
|
|
502
|
+
placeholder={spec.id === 'ollama' ? 'http://localhost:11434' : 'https://...'}
|
|
503
|
+
/>
|
|
504
|
+
</>
|
|
505
|
+
)}
|
|
506
|
+
{models.length > 0 && (
|
|
507
|
+
<>
|
|
508
|
+
<label>Default Model</label>
|
|
509
|
+
<select value={cardModel} onChange={e => setCardModel(e.target.value)}>
|
|
510
|
+
<option value="">Keep current</option>
|
|
511
|
+
{models.map(m => (
|
|
512
|
+
<option key={m} value={m}>{friendlyModelName(m)}</option>
|
|
513
|
+
))}
|
|
514
|
+
</select>
|
|
515
|
+
</>
|
|
516
|
+
)}
|
|
517
|
+
<div className="provider-actions">
|
|
518
|
+
<button
|
|
519
|
+
className="btn-save"
|
|
520
|
+
onClick={() => handleSaveProvider(spec.id)}
|
|
521
|
+
disabled={saving || (!cardApiKey && spec.needsKey && !isConfigured && !spec.needsEndpoint)}
|
|
522
|
+
>
|
|
523
|
+
{saving ? 'Saving...' : 'Save'}
|
|
524
|
+
</button>
|
|
525
|
+
</div>
|
|
526
|
+
</>
|
|
527
|
+
)}
|
|
528
|
+
</div>
|
|
529
|
+
)}
|
|
530
|
+
</div>
|
|
531
|
+
);
|
|
532
|
+
})}
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
);
|
|
537
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { api } from '../../api';
|
|
3
|
+
import { PasswordStrength } from '../../components/PasswordStrength';
|
|
4
|
+
|
|
5
|
+
export function SettingsSecurity() {
|
|
6
|
+
// Dashboard password state
|
|
7
|
+
const [dashOld, setDashOld] = useState('');
|
|
8
|
+
const [dashNew, setDashNew] = useState('');
|
|
9
|
+
const [dashConfirm, setDashConfirm] = useState('');
|
|
10
|
+
const [dashSaving, setDashSaving] = useState(false);
|
|
11
|
+
const [dashSuccess, setDashSuccess] = useState('');
|
|
12
|
+
const [dashError, setDashError] = useState('');
|
|
13
|
+
|
|
14
|
+
// Vault password state
|
|
15
|
+
const [vaultNew, setVaultNew] = useState('');
|
|
16
|
+
const [vaultConfirm, setVaultConfirm] = useState('');
|
|
17
|
+
const [vaultSaving, setVaultSaving] = useState(false);
|
|
18
|
+
const [vaultSuccess, setVaultSuccess] = useState('');
|
|
19
|
+
const [vaultError, setVaultError] = useState('');
|
|
20
|
+
|
|
21
|
+
const handleDashboardPassword = async (e: React.FormEvent) => {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
setDashError('');
|
|
24
|
+
setDashSuccess('');
|
|
25
|
+
if (dashNew !== dashConfirm) {
|
|
26
|
+
setDashError('New passwords do not match');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (dashNew.length < 8) {
|
|
30
|
+
setDashError('Password must be at least 8 characters');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
setDashSaving(true);
|
|
34
|
+
try {
|
|
35
|
+
await api.changeDashboardPassword(dashOld, dashNew);
|
|
36
|
+
setDashSuccess('Mission Control password changed');
|
|
37
|
+
setDashOld('');
|
|
38
|
+
setDashNew('');
|
|
39
|
+
setDashConfirm('');
|
|
40
|
+
} catch (err: any) {
|
|
41
|
+
setDashError(err.message);
|
|
42
|
+
} finally {
|
|
43
|
+
setDashSaving(false);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const handleVaultPassword = async (e: React.FormEvent) => {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
setVaultError('');
|
|
50
|
+
setVaultSuccess('');
|
|
51
|
+
if (vaultNew !== vaultConfirm) {
|
|
52
|
+
setVaultError('New passwords do not match');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (vaultNew.length < 8) {
|
|
56
|
+
setVaultError('Password must be at least 8 characters');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
setVaultSaving(true);
|
|
60
|
+
try {
|
|
61
|
+
await api.changeVaultPassword(vaultNew);
|
|
62
|
+
setVaultSuccess('Vault password changed');
|
|
63
|
+
setVaultNew('');
|
|
64
|
+
setVaultConfirm('');
|
|
65
|
+
} catch (err: any) {
|
|
66
|
+
setVaultError(err.message);
|
|
67
|
+
} finally {
|
|
68
|
+
setVaultSaving(false);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="page">
|
|
74
|
+
<h2>Security</h2>
|
|
75
|
+
|
|
76
|
+
<div className="settings-section">
|
|
77
|
+
<h3>Change Mission Control Password</h3>
|
|
78
|
+
<form className="settings-form" onSubmit={handleDashboardPassword}>
|
|
79
|
+
<label>Current Password</label>
|
|
80
|
+
<input type="password" value={dashOld} onChange={(e) => setDashOld(e.target.value)} />
|
|
81
|
+
<label>New Password</label>
|
|
82
|
+
<input type="password" value={dashNew} onChange={(e) => setDashNew(e.target.value)} />
|
|
83
|
+
<PasswordStrength password={dashNew} />
|
|
84
|
+
<label>Confirm New Password</label>
|
|
85
|
+
<input type="password" value={dashConfirm} onChange={(e) => setDashConfirm(e.target.value)} />
|
|
86
|
+
<button className="settings-btn" type="submit" disabled={dashSaving || !dashOld || !dashNew}>
|
|
87
|
+
{dashSaving ? 'Saving...' : 'Change Password'}
|
|
88
|
+
</button>
|
|
89
|
+
{dashSuccess && <div className="settings-success">{dashSuccess}</div>}
|
|
90
|
+
{dashError && <div className="error">{dashError}</div>}
|
|
91
|
+
</form>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div className="settings-section">
|
|
95
|
+
<h3>Change Vault Password</h3>
|
|
96
|
+
<form className="settings-form" onSubmit={handleVaultPassword}>
|
|
97
|
+
<label>New Vault Password</label>
|
|
98
|
+
<input type="password" value={vaultNew} onChange={(e) => setVaultNew(e.target.value)} />
|
|
99
|
+
<PasswordStrength password={vaultNew} />
|
|
100
|
+
<label>Confirm New Password</label>
|
|
101
|
+
<input type="password" value={vaultConfirm} onChange={(e) => setVaultConfirm(e.target.value)} />
|
|
102
|
+
<button className="settings-btn" type="submit" disabled={vaultSaving || !vaultNew}>
|
|
103
|
+
{vaultSaving ? 'Saving...' : 'Change Vault Password'}
|
|
104
|
+
</button>
|
|
105
|
+
{vaultSuccess && <div className="settings-success">{vaultSuccess}</div>}
|
|
106
|
+
{vaultError && <div className="error">{vaultError}</div>}
|
|
107
|
+
</form>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|