Skip to content

Commit e8abb13

Browse files
committed
feat(web): Generate-panel inline API-key entry (feature gap #2)
Completes the original feature: a user who selects a provider with no stored key can now enter it in the UI. Previously the backend secrets routes existed and were tested, but the frontend never called them — there was no way to add a key through the web app. - index.html: inline key-entry row, shown only when the selected provider is keyed (adapter != bedrock) AND not yet available. Password input + 'Test & save' -> POST /api/content/secrets; on success clears the raw key from memory, re-fetches /providers so the option flips to enabled, shows a 'verified & stored encrypted' confirmation; on 400/422 shows the provider's rejection message. needsKey / selectedProvider getters; keyEntry state reset on provider change. - style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing palette vars; password/text inputs styled to match selects. - test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for unavailable keyed provider, hidden for available one, save POSTs correct {provider,key} body + shows success, rejection shows error. Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c) the feature now works end-to-end: select provider -> enter missing key -> tested + stored encrypted -> consumed at generation.
1 parent 4bcfd2b commit e8abb13

3 files changed

Lines changed: 298 additions & 2 deletions

File tree

packages/studyloop/src/studyloop/web/static/index.html

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -802,14 +802,37 @@ <h2>Generate</h2>
802802
<select x-model="form.provider" @change="onProviderChange()">
803803
<option value="">— pick a provider —</option>
804804
<template x-for="p in providers" :key="p.slug">
805-
<option :value="p.slug" :disabled="!p.available">
805+
<option :value="p.slug">
806806
<span x-text="p.label"></span>
807-
<span x-show="!p.available">set <span x-text="p.auth_env"></span> to enable</span>
807+
<span x-show="!p.available">needs API key</span>
808808
</option>
809809
</template>
810810
</select>
811811
</label>
812812

813+
<!-- Inline key-entry: shown when the chosen provider has no key and
814+
uses an API key (bedrock is AWS-SDK auth, never keyed here). -->
815+
<div class="form-row api-key-entry"
816+
x-show="needsKey"
817+
x-transition>
818+
<span>API key</span>
819+
<div style="display:flex; flex-direction:column; gap:0.35rem;">
820+
<div style="display:flex; gap:0.4rem;">
821+
<input type="password" x-model="keyEntry.value"
822+
:placeholder="'Paste your ' + (selectedProvider?.label || '') + ' API key'"
823+
autocomplete="off" spellcheck="false"
824+
style="flex:1;"
825+
@keydown.enter.prevent="saveKey()" />
826+
<button type="button" @click="saveKey()"
827+
:disabled="keyEntry.saving || !keyEntry.value.trim()"
828+
x-text="keyEntry.saving ? 'Testing…' : 'Test & save'"></button>
829+
</div>
830+
<small x-show="keyEntry.error" class="key-error" x-text="keyEntry.error"></small>
831+
<small x-show="keyEntry.saved" class="key-ok">✓ Key verified and stored encrypted in ~/.config/studyloop/</small>
832+
<small class="key-hint">Tested against the provider before saving. Stored encrypted; never leaves this machine.</small>
833+
</div>
834+
</div>
835+
813836
<label class="form-row" x-show="form.provider">
814837
<span>Model</span>
815838
<select x-model="form.model">
@@ -1322,6 +1345,9 @@ <h2>Start a Study Session</h2>
13221345
formError: '',
13231346
conflictBanner: '',
13241347

1348+
// Inline API-key entry (shown when the chosen provider has no stored key).
1349+
keyEntry: { value: '', saving: false, error: '', saved: false },
1350+
13251351
// Job state. `running` gates the form vs progress UI.
13261352
running: false,
13271353
jobId: '',
@@ -1335,6 +1361,18 @@ <h2>Start a Study Session</h2>
13351361
return p ? p.models : [];
13361362
},
13371363

1364+
get selectedProvider() {
1365+
return this.providers.find((p) => p.slug === this.form.provider) || null;
1366+
},
1367+
1368+
get needsKey() {
1369+
// Show the key-entry form only for a chosen, keyed provider that is
1370+
// not yet available. Bedrock uses AWS-SDK auth (adapter === 'bedrock')
1371+
// and is never keyed here.
1372+
const p = this.selectedProvider;
1373+
return !!p && !p.available && p.adapter !== 'bedrock';
1374+
},
1375+
13381376
async init() {
13391377
// The tree is 3-level: publisher → course → lesson file. Load the
13401378
// publishers (study-tree top level) and the LLM providers up front;
@@ -1394,6 +1432,47 @@ <h2>Start a Study Session</h2>
13941432
// care about the openai_compat / anthropic_compat distinction.
13951433
const p = this.providers.find((x) => x.slug === this.form.provider);
13961434
this.form.backend = p ? p.adapter : '';
1435+
// Reset the key-entry form for the newly-selected provider.
1436+
this.keyEntry = { value: '', saving: false, error: '', saved: false };
1437+
},
1438+
1439+
async refreshProviders() {
1440+
// Re-fetch the provider list so `available` reflects a just-saved key.
1441+
try {
1442+
const r = await fetch('/api/content/providers');
1443+
if (r.ok) this.providers = await r.json();
1444+
} catch { /* keep the existing list */ }
1445+
},
1446+
1447+
async saveKey() {
1448+
const value = this.keyEntry.value.trim();
1449+
if (!value || this.keyEntry.saving) return;
1450+
this.keyEntry.saving = true;
1451+
this.keyEntry.error = '';
1452+
this.keyEntry.saved = false;
1453+
try {
1454+
const r = await fetch('/api/content/secrets', {
1455+
method: 'POST',
1456+
headers: { 'Content-Type': 'application/json' },
1457+
body: JSON.stringify({ provider: this.form.provider, key: value }),
1458+
});
1459+
if (r.ok) {
1460+
// Tested + stored. Refresh providers so the option is enabled,
1461+
// clear the raw key from memory, and confirm.
1462+
this.keyEntry.value = '';
1463+
this.keyEntry.saved = true;
1464+
await this.refreshProviders();
1465+
} else {
1466+
// 400 = provider rejected the key; 422 = unknown/keyless provider.
1467+
let detail = 'Could not verify the key.';
1468+
try { detail = (await r.json()).detail || detail; } catch { /* keep default */ }
1469+
this.keyEntry.error = detail;
1470+
}
1471+
} catch (e) {
1472+
this.keyEntry.error = 'Network error testing the key. Is the server reachable?';
1473+
} finally {
1474+
this.keyEntry.saving = false;
1475+
}
13971476
},
13981477

13991478
canSubmit() {

packages/studyloop/src/studyloop/web/static/style.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2666,6 +2666,21 @@ body[data-palette="everforest"] {
26662666
}
26672667
.generate-form select:focus,
26682668
.generate-form input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
2669+
.generate-form input[type="password"],
2670+
.generate-form input[type="text"] {
2671+
background: var(--bg-card);
2672+
border: 1px solid var(--border);
2673+
border-radius: 8px;
2674+
color: var(--text);
2675+
padding: 6px 10px;
2676+
font: inherit;
2677+
font-size: 0.9rem;
2678+
}
2679+
2680+
/* Inline API-key entry (shown when a chosen provider has no stored key). */
2681+
.generate-form .api-key-entry .key-error { color: var(--danger, #e5534b); }
2682+
.generate-form .api-key-entry .key-ok { color: var(--success, #3fb950); }
2683+
.generate-form .api-key-entry .key-hint { color: var(--text-muted); opacity: 0.8; }
26692684

26702685
.generate-banner {
26712686
border-left: 3px solid var(--accent);
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"""E2E for the Generate-panel inline API-key entry (feature gap #2).
2+
3+
Drives a real browser against a real ``studyloop web`` server. The provider
4+
list and the secrets POST are route-intercepted so the test is deterministic
5+
and never hits a real LLM provider — but the UI logic (show/hide the key form,
6+
POST the right body, reflect success) is exercised for real.
7+
8+
Port 18582 (sisters use 18580 content-gen, 18581 struggling-topics).
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
import os
15+
import subprocess
16+
import sys
17+
import time
18+
import urllib.error
19+
import urllib.request
20+
from collections.abc import Generator
21+
from pathlib import Path
22+
23+
import pytest
24+
from playwright.sync_api import Browser, Page
25+
26+
pytestmark = [pytest.mark.e2e]
27+
28+
WEB_PORT = 18582
29+
30+
31+
@pytest.fixture
32+
def stub_config(tmp_path: Path) -> Path:
33+
cfg = tmp_path / "studyloop-keyentry.yaml"
34+
cfg.write_text(
35+
f"session_db: {tmp_path / 'sessions.db'}\n"
36+
"card_generator:\n backend: stub\n",
37+
encoding="utf-8",
38+
)
39+
return cfg
40+
41+
42+
@pytest.fixture
43+
def server(stub_config: Path) -> Generator[subprocess.Popen, None, None]:
44+
env = os.environ.copy()
45+
env["STUDYLOOP_CONFIG"] = str(stub_config)
46+
proc = subprocess.Popen(
47+
[sys.executable, "-m", "studyloop.cli", "web", "--port", str(WEB_PORT)],
48+
stdout=subprocess.DEVNULL,
49+
stderr=subprocess.DEVNULL,
50+
env=env,
51+
)
52+
for _ in range(40):
53+
try:
54+
urllib.request.urlopen(f"http://127.0.0.1:{WEB_PORT}/", timeout=1)
55+
break
56+
except urllib.error.HTTPError:
57+
break
58+
except Exception:
59+
time.sleep(0.3)
60+
else:
61+
proc.kill()
62+
raise RuntimeError(f"web server failed to start on {WEB_PORT}")
63+
try:
64+
yield proc
65+
finally:
66+
proc.terminate()
67+
try:
68+
proc.wait(timeout=10)
69+
except Exception:
70+
proc.kill()
71+
proc.wait(timeout=5)
72+
73+
74+
# Provider list with anthropic NOT available (no key) — the trigger for the form.
75+
_PROVIDERS = [
76+
{
77+
"slug": "anthropic",
78+
"label": "Anthropic",
79+
"adapter": "anthropic_compat",
80+
"auth_env": "ANTHROPIC_API_KEY",
81+
"available": False,
82+
"models": [{"id": "claude-haiku-4-5", "label": "Haiku", "cost_tier": "cheap", "thinking": False, "notes": ""}],
83+
},
84+
{
85+
"slug": "openai",
86+
"label": "OpenAI",
87+
"adapter": "openai_compat",
88+
"auth_env": "OPENAI_API_KEY",
89+
"available": True, # already has a key — form must NOT show
90+
"models": [{"id": "gpt-x", "label": "GPT-X", "cost_tier": "balanced", "thinking": False, "notes": ""}],
91+
},
92+
]
93+
94+
95+
def _route_providers(page: Page, *, anthropic_available: bool) -> None:
96+
providers = json.loads(json.dumps(_PROVIDERS))
97+
providers[0]["available"] = anthropic_available
98+
page.route(
99+
"**/api/content/providers",
100+
lambda route: route.fulfill(
101+
status=200, content_type="application/json", body=json.dumps(providers)
102+
),
103+
)
104+
105+
106+
def _goto_generate(page: Page) -> None:
107+
page.goto(f"http://127.0.0.1:{WEB_PORT}/#generate")
108+
page.wait_for_load_state("domcontentloaded")
109+
page.wait_for_function("() => !!window.Alpine", timeout=5000)
110+
page.wait_for_function(
111+
"() => window.Alpine.store('nav').current === 'generate'", timeout=3000
112+
)
113+
114+
115+
@pytest.fixture
116+
def page(server, browser: Browser) -> Generator[Page, None, None]:
117+
context = browser.new_context()
118+
p = context.new_page()
119+
try:
120+
yield p
121+
finally:
122+
p.close()
123+
context.close()
124+
125+
126+
class TestKeyEntryUI:
127+
def test_key_form_appears_for_unavailable_keyed_provider(self, page: Page) -> None:
128+
_route_providers(page, anthropic_available=False)
129+
_goto_generate(page)
130+
page.wait_for_function(
131+
"() => window.Alpine.$data(document.querySelector('[x-data=\"generatePanel()\"]')).providers.length > 0",
132+
timeout=3000,
133+
)
134+
# Select anthropic (no key) → the inline key form must appear.
135+
page.select_option('select[x-model="form.provider"]', "anthropic")
136+
page.wait_for_selector(".api-key-entry input[type='password']", state="visible", timeout=3000)
137+
assert page.is_visible(".api-key-entry input[type='password']")
138+
139+
def test_key_form_hidden_for_available_provider(self, page: Page) -> None:
140+
_route_providers(page, anthropic_available=False)
141+
_goto_generate(page)
142+
page.wait_for_function(
143+
"() => window.Alpine.$data(document.querySelector('[x-data=\"generatePanel()\"]')).providers.length > 0",
144+
timeout=3000,
145+
)
146+
# OpenAI is available → no key form.
147+
page.select_option('select[x-model="form.provider"]', "openai")
148+
page.wait_for_timeout(300)
149+
assert not page.is_visible(".api-key-entry input[type='password']")
150+
151+
def test_save_key_posts_and_shows_success(self, page: Page) -> None:
152+
_route_providers(page, anthropic_available=False)
153+
154+
posted: dict = {}
155+
156+
def handle_post(route):
157+
req = route.request
158+
posted["body"] = req.post_data_json
159+
# After a successful save the UI re-fetches providers; flip anthropic
160+
# to available so the success path is realistic.
161+
_route_providers(page, anthropic_available=True)
162+
route.fulfill(status=200, content_type="application/json", body=json.dumps({"ok": True}))
163+
164+
page.route("**/api/content/secrets", handle_post)
165+
166+
_goto_generate(page)
167+
page.wait_for_function(
168+
"() => window.Alpine.$data(document.querySelector('[x-data=\"generatePanel()\"]')).providers.length > 0",
169+
timeout=3000,
170+
)
171+
page.select_option('select[x-model="form.provider"]', "anthropic")
172+
page.wait_for_selector(".api-key-entry input[type='password']", state="visible", timeout=3000)
173+
page.fill(".api-key-entry input[type='password']", "sk-test-12345")
174+
page.click(".api-key-entry button")
175+
176+
# Success message appears.
177+
page.wait_for_selector(".api-key-entry .key-ok", state="visible", timeout=3000)
178+
assert page.is_visible(".api-key-entry .key-ok")
179+
# The POST carried the right provider + key.
180+
assert posted["body"] == {"provider": "anthropic", "key": "sk-test-12345"}
181+
182+
def test_save_key_shows_error_on_rejection(self, page: Page) -> None:
183+
_route_providers(page, anthropic_available=False)
184+
page.route(
185+
"**/api/content/secrets",
186+
lambda route: route.fulfill(
187+
status=400,
188+
content_type="application/json",
189+
body=json.dumps({"detail": "Provider rejected the key (401)."}),
190+
),
191+
)
192+
_goto_generate(page)
193+
page.wait_for_function(
194+
"() => window.Alpine.$data(document.querySelector('[x-data=\"generatePanel()\"]')).providers.length > 0",
195+
timeout=3000,
196+
)
197+
page.select_option('select[x-model="form.provider"]', "anthropic")
198+
page.wait_for_selector(".api-key-entry input[type='password']", state="visible", timeout=3000)
199+
page.fill(".api-key-entry input[type='password']", "bad-key")
200+
page.click(".api-key-entry button")
201+
page.wait_for_selector(".api-key-entry .key-error", state="visible", timeout=3000)
202+
assert "rejected" in page.inner_text(".api-key-entry .key-error").lower()

0 commit comments

Comments
 (0)