Skip to content

Commit 7ced772

Browse files
committed
Validate API keys with a tiny round-trip from the Settings page
When the user fills in an API key, give them an immediate ✓/✗ instead of waiting until a 4xx surfaces mid-pipeline. Each provider's lightest authenticated endpoint is hit: Canvas → GET /api/v1/users/self (uses cfg-canvas-url) OpenAI → GET /v1/models Anthropic → GET /v1/models?limit=1 (x-api-key + anthropic-version) Gemini → GET /v1beta/models?key=… (OpenAI-compat layer) DeepSeek → GET /models Grok → GET /v1/models Mistral → GET /v1/models These are auth-only — no completion tokens consumed, so repeated tests don't cost anything. 401/403 surfaces as "Auth failed"; 4xx with a JSON error body (e.g. DeepSeek "Insufficient balance") forwards the message verbatim. UI: each credential field gains an inline "Test" button that updates a status pill below it; a "Test all keys" button fires every non-empty field in parallel; Save All Settings auto-runs the same fan-out so the user sees the result without an extra click.
1 parent 6a532b3 commit 7ced772

5 files changed

Lines changed: 234 additions & 10 deletions

File tree

electron/main.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -968,13 +968,118 @@ function rmRecursive(dirPath) {
968968
fs.rmSync(dirPath, { recursive: true, force: true });
969969
}
970970

971+
// ── Credential validation ─────────────────────────────────────────────────────
972+
// Send a tiny authenticated round-trip to each provider's lightest endpoint
973+
// to confirm the key reaches the API and decodes. We hit list-models routes
974+
// (`GET /models` or equivalent) wherever possible — they exercise the same
975+
// auth path as a real completion call but consume zero output tokens, so
976+
// repeated tests don't cost anything.
977+
function httpRequest(method, urlStr, headers, body, timeoutMs) {
978+
return new Promise((resolve) => {
979+
let parsed;
980+
try { parsed = new URL(urlStr); } catch (e) {
981+
return resolve({ ok: false, status: 0, body: '', error: 'Invalid URL' });
982+
}
983+
const mod = parsed.protocol === 'https:' ? https : http;
984+
const opts = {
985+
method,
986+
hostname: parsed.hostname,
987+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
988+
path: parsed.pathname + parsed.search,
989+
headers: headers || {},
990+
};
991+
const req = mod.request(opts, (res) => {
992+
let buf = '';
993+
res.on('data', d => { buf += d; });
994+
res.on('end', () => resolve({
995+
ok: res.statusCode >= 200 && res.statusCode < 300,
996+
status: res.statusCode,
997+
body: buf,
998+
}));
999+
});
1000+
req.on('error', (err) => resolve({ ok: false, status: 0, body: '', error: String(err.message || err) }));
1001+
req.setTimeout(timeoutMs || 8000, () => {
1002+
req.destroy();
1003+
resolve({ ok: false, status: 0, body: '', error: 'Request timed out' });
1004+
});
1005+
if (body) req.write(body);
1006+
req.end();
1007+
});
1008+
}
1009+
1010+
async function testCredential(provider, key, extra) {
1011+
if (!key || !String(key).trim()) {
1012+
return { ok: false, message: 'No key entered' };
1013+
}
1014+
key = String(key).trim();
1015+
let url, headers, method = 'GET', body = null;
1016+
switch (provider) {
1017+
case 'canvas': {
1018+
let baseUrl = (extra && extra.canvasUrl ? String(extra.canvasUrl) :
1019+
(loadConfig().CANVAS_URL || '')).trim().replace(/\/$/, '');
1020+
if (!baseUrl) return { ok: false, message: 'Set Canvas URL first' };
1021+
if (!baseUrl.startsWith('http')) baseUrl = 'https://' + baseUrl;
1022+
url = `${baseUrl}/api/v1/users/self`;
1023+
headers = { 'Authorization': `Bearer ${key}` };
1024+
break;
1025+
}
1026+
case 'openai':
1027+
url = 'https://api.openai.com/v1/models';
1028+
headers = { 'Authorization': `Bearer ${key}` };
1029+
break;
1030+
case 'anthropic':
1031+
url = 'https://api.anthropic.com/v1/models?limit=1';
1032+
headers = { 'x-api-key': key, 'anthropic-version': '2023-06-01' };
1033+
break;
1034+
case 'gemini':
1035+
// Gemini auth-check via OpenAI-compat layer — matches what we use
1036+
// at runtime in note_generation._make_client.
1037+
url = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(key)}&pageSize=1`;
1038+
headers = {};
1039+
break;
1040+
case 'deepseek':
1041+
url = 'https://api.deepseek.com/models';
1042+
headers = { 'Authorization': `Bearer ${key}` };
1043+
break;
1044+
case 'grok':
1045+
url = 'https://api.x.ai/v1/models';
1046+
headers = { 'Authorization': `Bearer ${key}` };
1047+
break;
1048+
case 'mistral':
1049+
url = 'https://api.mistral.ai/v1/models';
1050+
headers = { 'Authorization': `Bearer ${key}` };
1051+
break;
1052+
default:
1053+
return { ok: false, message: `Unknown provider: ${provider}` };
1054+
}
1055+
const r = await httpRequest(method, url, headers, body, 8000);
1056+
if (r.ok) return { ok: true, message: 'Key valid' };
1057+
if (r.error) return { ok: false, message: r.error };
1058+
if (r.status === 401 || r.status === 403) {
1059+
return { ok: false, message: `Auth failed (${r.status})` };
1060+
}
1061+
if (r.status === 404 && provider === 'canvas') {
1062+
return { ok: false, message: '404 — check Canvas URL' };
1063+
}
1064+
// Surface a tail of the body so DeepSeek's "Insufficient balance" /
1065+
// OpenAI's "incorrect_api_key" messages reach the user verbatim.
1066+
let detail = '';
1067+
try {
1068+
const j = JSON.parse(r.body || '');
1069+
detail = (j.error && (j.error.message || j.error)) || j.message || '';
1070+
} catch { detail = (r.body || '').slice(0, 120); }
1071+
return { ok: false, message: `HTTP ${r.status}${detail ? ': ' + String(detail).slice(0, 160) : ''}` };
1072+
}
1073+
9711074
// ── IPC handlers ──────────────────────────────────────────────────────────────
9721075
function registerIpc() {
9731076
ipcMain.handle('config:get', () => loadConfig());
9741077
ipcMain.handle('config:set', (_, d) => { saveConfig(d); return true; });
9751078

9761079
ipcMain.handle('credentials:get', () => loadCredentials());
9771080
ipcMain.handle('credentials:set', (_, d) => { saveCredentials(d); return true; });
1081+
ipcMain.handle('credentials:test', (_, { provider, key, extra }) =>
1082+
testCredential(provider, key, extra || {}));
9781083

9791084
ipcMain.handle('courses:fetch', () => fetchCoursesFromCanvas());
9801085

electron/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "auto-note",
3-
"version": "1.0.2",
3+
"version": "1.0.3",
44
"description": "AutoNote — lecture notes generator from Canvas recordings",
55
"homepage": "https://github.com/nodeeeeee/Auto-Note",
66
"author": {

electron/preload.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ contextBridge.exposeInMainWorld('api', {
1010
// ── Credentials ───────────────────────────────────────────────────────────
1111
getCredentials: () => ipcRenderer.invoke('credentials:get'),
1212
setCredentials: (data) => ipcRenderer.invoke('credentials:set', data),
13+
testCredential: (provider, key, extra) =>
14+
ipcRenderer.invoke('credentials:test', { provider, key, extra }),
1315

1416
// ── Courses (Canvas API) ──────────────────────────────────────────────────
1517
fetchCourses: () => ipcRenderer.invoke('courses:fetch'),

electron/renderer/app.js

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -274,19 +274,86 @@ function mkCheckbox(id, label, checked = true) {
274274
</label>`;
275275
}
276276

277-
function mkRevealField(id, placeholder, value = '') {
277+
function mkRevealField(id, placeholder, value = '', provider = '') {
278+
// When `provider` is set, render an extra "Test" button that fires a tiny
279+
// authenticated round-trip against that provider's API to verify the key
280+
// is reachable + valid before the user runs a real pipeline.
281+
const testBtn = provider
282+
? `<button class="test-btn" id="${id}-test" data-provider="${provider}"
283+
data-target="${id}" title="Test connection">Test</button>`
284+
: '';
285+
const status = provider
286+
? `<span class="key-status" id="${id}-status"></span>`
287+
: '';
278288
return `<div class="reveal-wrap">
279289
<input type="password" id="${id}" class="input-text"
280290
placeholder="${esc(placeholder)}" value="${esc(value)}">
281291
<button class="reveal-btn" onclick="toggleReveal('${id}')">👁</button>
282-
</div>`;
292+
${testBtn}
293+
</div>${status}`;
283294
}
284295

285296
function toggleReveal(id) {
286297
const el = document.getElementById(id);
287298
if (el) el.type = el.type === 'password' ? 'text' : 'password';
288299
}
289300

301+
async function testAllCredentials() {
302+
// Fan out one test per non-empty field. Skips fields the user hasn't
303+
// filled in — testing an empty Anthropic key when the user only uses
304+
// OpenAI would just produce noise.
305+
const btn = document.getElementById('test-all-keys-btn');
306+
if (btn) btn.disabled = true;
307+
const buttons = document.querySelectorAll('.test-btn');
308+
const tests = [];
309+
for (const b of buttons) {
310+
const tid = b.dataset.target;
311+
const v = (document.getElementById(tid)?.value || '').trim();
312+
if (!v) continue; // skip empty fields silently
313+
tests.push(testCredentialField(b));
314+
}
315+
await Promise.all(tests);
316+
if (btn) btn.disabled = false;
317+
}
318+
319+
async function testCredentialField(btn) {
320+
const provider = btn.dataset.provider;
321+
const targetId = btn.dataset.target;
322+
const input = document.getElementById(targetId);
323+
const status = document.getElementById(`${targetId}-status`);
324+
if (!input || !status) return;
325+
const key = (input.value || '').trim();
326+
if (!key) {
327+
status.textContent = '— enter a key first';
328+
status.className = 'key-status warn';
329+
return;
330+
}
331+
// Canvas needs the URL too; pull it straight from the form so the user
332+
// doesn't have to Save All before testing.
333+
const extra = {};
334+
if (provider === 'canvas') {
335+
extra.canvasUrl = (document.getElementById('cfg-canvas-url')?.value || '').trim();
336+
}
337+
btn.disabled = true;
338+
status.textContent = 'Testing…';
339+
status.className = 'key-status pending';
340+
try {
341+
const r = await window.api.testCredential(provider, key, extra);
342+
if (r.ok) {
343+
status.textContent = '✓ ' + r.message;
344+
status.className = 'key-status ok';
345+
} else {
346+
status.textContent = '✗ ' + r.message;
347+
status.className = 'key-status err';
348+
}
349+
} catch (e) {
350+
status.textContent = '✗ ' + (e.message || e);
351+
status.className = 'key-status err';
352+
} finally {
353+
btn.disabled = false;
354+
}
355+
}
356+
290357
// ── Run pipeline command ───────────────────────────────────────────────────────
291358
function runCmd(cmd, label = '') {
292359
if (State.running) {
@@ -1284,27 +1351,41 @@ async function loadSettingsData() {
12841351
keysEl.innerHTML = `
12851352
<div class="row">
12861353
<div class="col expand"><label class="label">Canvas Token</label>
1287-
${mkRevealField('cred-canvas', 'Canvas API token', creds.canvas)}</div>
1354+
${mkRevealField('cred-canvas', 'Canvas API token', creds.canvas, 'canvas')}</div>
12881355
</div>
12891356
<div class="row">
12901357
<div class="col expand"><label class="label">OpenAI API Key</label>
1291-
${mkRevealField('cred-openai', 'sk-…', creds.openai)}</div>
1358+
${mkRevealField('cred-openai', 'sk-…', creds.openai, 'openai')}</div>
12921359
<div class="col expand"><label class="label">Anthropic API Key</label>
1293-
${mkRevealField('cred-anthropic', 'sk-ant-…', creds.anthropic)}</div>
1360+
${mkRevealField('cred-anthropic', 'sk-ant-…', creds.anthropic, 'anthropic')}</div>
12941361
</div>
12951362
<div class="row">
12961363
<div class="col expand"><label class="label">Gemini API Key</label>
1297-
${mkRevealField('cred-gemini', 'AIza…', creds.gemini)}</div>
1364+
${mkRevealField('cred-gemini', 'AIza…', creds.gemini, 'gemini')}</div>
12981365
<div class="col expand"><label class="label">DeepSeek API Key</label>
1299-
${mkRevealField('cred-deepseek', 'sk-…', creds.deepseek)}</div>
1366+
${mkRevealField('cred-deepseek', 'sk-…', creds.deepseek, 'deepseek')}</div>
13001367
</div>
13011368
<div class="row">
13021369
<div class="col expand"><label class="label">xAI (Grok) API Key</label>
1303-
${mkRevealField('cred-grok', 'xai-…', creds.grok)}</div>
1370+
${mkRevealField('cred-grok', 'xai-…', creds.grok, 'grok')}</div>
13041371
<div class="col expand"><label class="label">Mistral API Key</label>
1305-
${mkRevealField('cred-mistral', 'key…', creds.mistral)}</div>
1372+
${mkRevealField('cred-mistral', 'key…', creds.mistral, 'mistral')}</div>
1373+
</div>
1374+
<div class="row">
1375+
<div class="col expand">
1376+
<button class="btn-secondary" id="test-all-keys-btn">Test all keys</button>
1377+
<span class="hint">Each "Test" button sends a single auth-only
1378+
request to the provider — no completion tokens are billed.</span>
1379+
</div>
13061380
</div>
13071381
`;
1382+
// Wire up per-field Test buttons + the bulk "Test all" button. Use
1383+
// event delegation so dynamically-rendered buttons work.
1384+
keysEl.addEventListener('click', (e) => {
1385+
const btn = e.target.closest('.test-btn');
1386+
if (btn) testCredentialField(btn);
1387+
});
1388+
document.getElementById('test-all-keys-btn')?.addEventListener('click', testAllCredentials);
13081389
}
13091390

13101391
// Venv status
@@ -1391,6 +1472,13 @@ async function saveAllSettings() {
13911472

13921473
if (btn) btn.disabled = false;
13931474

1475+
// Verify each API key the user just saved by firing a tiny auth-only
1476+
// round-trip per provider. Failures show inline next to the field; we
1477+
// don't block the save — the user might be deliberately offline.
1478+
if (typeof testAllCredentials === 'function') {
1479+
testAllCredentials().catch(() => {});
1480+
}
1481+
13941482
if (errors.length) {
13951483
snack('Errors: ' + errors.join('; '), false);
13961484
} else {

electron/renderer/style.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,35 @@ input, select, textarea { font: inherit; }
361361
}
362362
.reveal-btn:hover { color: var(--c-white-88); }
363363

364+
/* When a Test button sits next to the eye, push the eye left so they
365+
don't overlap. The Test button is rendered after the reveal button. */
366+
.reveal-wrap .test-btn ~ * {} /* placeholder for spec hierarchy */
367+
.reveal-wrap:has(.test-btn) .reveal-btn { right: 56px; }
368+
.test-btn {
369+
position: absolute; right: 6px;
370+
background: rgba(255, 255, 255, 0.06);
371+
border: 1px solid var(--c-white-20, rgba(255, 255, 255, 0.2));
372+
border-radius: 4px;
373+
cursor: pointer;
374+
color: var(--c-white-88, #ddd);
375+
font-size: 11px;
376+
padding: 2px 8px;
377+
transition: background var(--transition);
378+
}
379+
.test-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.14); }
380+
.test-btn:disabled { opacity: 0.5; cursor: wait; }
381+
382+
.key-status {
383+
display: inline-block;
384+
margin-top: 4px;
385+
font-size: 11px;
386+
font-family: var(--font-mono);
387+
}
388+
.key-status.ok { color: var(--c-success, #6BCB6B); }
389+
.key-status.err { color: var(--c-error, #FF6B6B); }
390+
.key-status.warn { color: var(--c-warn, #FFB347); }
391+
.key-status.pending { color: var(--c-white-45, rgba(255,255,255,0.45)); }
392+
364393
.textarea-ctrl {
365394
resize: vertical;
366395
min-height: 80px;

0 commit comments

Comments
 (0)