diff --git a/dist/page_css.c b/dist/page_css.c index 1629300..8682d15 100644 --- a/dist/page_css.c +++ b/dist/page_css.c @@ -1903,7 +1903,11 @@ el_val_t page_css(void) { " text-align: center;\n" " padding: 0.875rem 1.5rem;\n" " transition: background 300ms, opacity 300ms;\n" + " background: var(--navy);\n" + " color: #fff;\n" + " box-shadow: 0 2px 16px rgba(0,82,160,.25);\n" " }\n" + " button.pricing-cta-navy:hover, button.pricing-cta-solid:hover, button.pricing-cta-ghost:hover { background: #0078D4; }\n" " button[disabled] { opacity: 0.6; cursor: not-allowed; }\n" "\n" " \n" diff --git a/migrations/20260511000000_user_api_keys.sql b/migrations/20260511000000_user_api_keys.sql new file mode 100644 index 0000000..92f0bf5 --- /dev/null +++ b/migrations/20260511000000_user_api_keys.sql @@ -0,0 +1,18 @@ +-- 20260511000000_user_api_keys.sql +-- +-- Stores user-provisioned AI provider API keys. +-- Service role only — the web backend verifies the user JWT before +-- reading or writing. No public or anon access. + +CREATE TABLE IF NOT EXISTS public.user_api_keys ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + user_id uuid NOT NULL, + provider text NOT NULL, -- 'openai' | 'anthropic' | 'gemini' | 'grok' + key_value text NOT NULL DEFAULT '', + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + UNIQUE(user_id, provider) +); + +ALTER TABLE public.user_api_keys ENABLE ROW LEVEL SECURITY; +CREATE POLICY "service only" ON public.user_api_keys USING (false); diff --git a/src/account.el b/src/account.el index 11f8214..e1268d1 100644 --- a/src/account.el +++ b/src/account.el @@ -493,7 +493,29 @@ fn account_css() -> String { .roadmap-items { list-style: none; display: flex; flex-direction: column; gap: .5rem; } .roadmap-items li { font-family: var(--body); font-size: .875rem; font-weight: 300; color: var(--t2); line-height: 1.6; padding-left: 1rem; position: relative; } .roadmap-items li::before { content: \"-\"; position: absolute; left: 0; color: var(--navy-65); } - .signout-section { padding-top: 1rem; display: flex; justify-content: flex-end; }" + .signout-section { padding-top: 1rem; display: flex; justify-content: flex-end; } + .api-key-list { display: flex; flex-direction: column; gap: 1.5rem; } + .api-key-entry { border-bottom: 1px solid var(--border); padding-bottom: 1.25rem; } + .api-key-entry:last-child { border-bottom: none; padding-bottom: 0; } + .api-key-header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: .625rem; } + .api-key-name { font-size: .875rem; font-weight: 500; color: var(--t1); } + .api-key-masked { font-size: .75rem; font-family: monospace; color: var(--t3); } + .api-key-row { display: flex; align-items: center; gap: .75rem; } + .api-key-actions { display: flex; gap: .5rem; flex-shrink: 0; } + .api-key-help { margin-top: .625rem; } + .api-key-help summary { font-size: .75rem; font-weight: 500; letter-spacing: .03em; color: var(--navy-65); cursor: pointer; list-style: none; padding: .25rem 0; user-select: none; } + .api-key-help summary::-webkit-details-marker { display: none; } + .api-key-help summary::before { content: \"\\25B8 \"; font-size: .6rem; } + .api-key-help[open] summary::before { content: \"\\25BE \"; } + .api-key-help-body { padding: .75rem 0 .125rem; } + .api-key-help-body ol { padding-left: 1.25rem; display: flex; flex-direction: column; gap: .375rem; } + .api-key-help-body li { font-size: .8125rem; font-weight: 300; color: var(--t2); line-height: 1.55; } + .api-key-help-body a { color: var(--navy-65); text-decoration: underline; text-underline-offset: 2px; } + .api-key-help-body a:hover { color: var(--navy); } + .api-key-note { font-size: .75rem; font-weight: 300; color: var(--t3); margin-top: .625rem; line-height: 1.55; padding: .5rem .75rem; background: var(--bg2); border-left: 2px solid var(--navy-b); } + .api-key-note a { color: var(--navy-65); text-decoration: underline; text-underline-offset: 2px; } + .api-key-model-note { font-size: .8125rem; font-weight: 300; color: var(--t2); line-height: 1.65; padding: .75rem 1rem; background: var(--navy-d); border-left: 2px solid var(--navy); margin-bottom: 1.5rem; } + .api-key-model-note strong { font-weight: 600; color: var(--t1); }" "" } @@ -804,6 +826,106 @@ fn account_devices_card() -> String { ) } +fn api_key_provider_row(provider_id: String, provider_name: String, placeholder: String, instructions: String) -> String { + el_div( + "class=\"api-key-entry\"", + el_div( + "class=\"api-key-header\"", + el_span("class=\"api-key-name\"", provider_name) + + el_span("class=\"api-key-masked\" id=\"apikey-masked-" + provider_id + "\"", "Not configured") + ) + + el_div( + "class=\"api-key-row\"", + "" + + el_div( + "class=\"api-key-actions\"", + el_button("type=\"button\" class=\"btn-primary\" style=\"padding:.5rem 1rem;font-size:.75rem\" onclick=\"saveApiKey('" + provider_id + "')\"", "Save") + + el_button("type=\"button\" class=\"btn-ghost\" style=\"padding:.5rem 1rem;font-size:.75rem;display:none\" id=\"apikey-del-" + provider_id + "\" onclick=\"deleteApiKey('" + provider_id + "')\"", "Remove") + ) + ) + + instructions + ) +} + +fn account_api_keys_section() -> String { + let openai_help: String = + "
" + + "How to get an OpenAI key" + + "
    " + + "
  1. Go to platform.openai.com/api-keys ↗
  2. " + + "
  3. Click Create new secret key
  4. " + + "
  5. Give it a name (e.g. “Neuron”), then click Create secret key
  6. " + + "
  7. Copy the key immediately — it is only shown once
  8. " + + "
" + + "

You need billing set up before making API calls. Add a payment method at platform.openai.com ↗

" + + "
" + + let anthropic_help: String = + "
" + + "How to get an Anthropic key" + + "
    " + + "
  1. Go to console.anthropic.com/settings/keys ↗
  2. " + + "
  3. Click Create Key
  4. " + + "
  5. Give it a name (e.g. “Neuron”), then click Create Key
  6. " + + "
  7. Copy the key immediately — it is only shown once
  8. " + + "
" + + "

You need to add credits before making API calls. Go to console.anthropic.com → Plans & Billing ↗ to add credits.

" + + "
" + + let gemini_help: String = + "
" + + "How to get a Gemini key" + + "
    " + + "
  1. Go to aistudio.google.com/apikey ↗
  2. " + + "
  3. Sign in with your Google account if prompted
  4. " + + "
  5. Click Create API key
  6. " + + "
  7. Select an existing Google Cloud project or create a new one
  8. " + + "
  9. Copy the key
  10. " + + "
" + + "

The free tier (Gemini 1.5 Flash) has generous rate limits. Paid usage is billed through your Google Cloud account.

" + + "
" + + let grok_help: String = + "
" + + "How to get a Grok key" + + "
    " + + "
  1. Go to console.x.ai ↗
  2. " + + "
  3. Sign in with your X (Twitter) account
  4. " + + "
  5. In the left sidebar, click API Keys
  6. " + + "
  7. Click Create API Key, give it a name
  8. " + + "
  9. Copy the key immediately — it is only shown once
  10. " + + "
" + + "

xAI offers free monthly credits to new accounts. Usage beyond the free tier is billed per token.

" + + "
" + + let providers: String = el_div( + "class=\"api-key-list\"", + api_key_provider_row("openai", "OpenAI", "sk-...", openai_help) + + api_key_provider_row("anthropic", "Anthropic", "sk-ant-...", anthropic_help) + + api_key_provider_row("gemini", "Gemini", "AIza...", gemini_help) + + api_key_provider_row("grok", "Grok", "xai-...", grok_help) + ) + el_div( + "id=\"api-keys-section\" style=\"display:none\"", + el_div( + "class=\"card-dark\"", + el_div( + "class=\"acct-section-header\"", + el_p("class=\"card-label\"", "API Keys") + + el_p("style=\"font-size:.8125rem;font-weight:300;color:var(--t2);line-height:1.65\"", + "Add your own AI provider keys. Neuron uses them directly — your keys, your models, your data." + ) + ) + + el_div( + "class=\"api-key-model-note\"", + "For best performance, use a reasoning model. o4-mini or o3 (OpenAI) · Claude Sonnet 4 (Anthropic) · Gemini 2.5 Pro (Google) · Grok-3 (xAI). You choose the model in the app — any model works, reasoning models are where Neuron shines. Neuron Inference — our own model layer, priced below the major APIs — launches Q3 2026 and becomes the default." + ) + + providers + + el_p("id=\"api-keys-msg\" style=\"display:none;font-size:.8rem;margin-top:.75rem\"", "") + ) + ) +} + fn account_dashboard_section() -> String { let header_row: String = el_div( "class=\"acct-header-row\"", @@ -828,6 +950,7 @@ fn account_dashboard_section() -> String { el_div( "class=\"account-section\"", account_plan_card() + + account_api_keys_section() + account_roadmap_section() + account_family_section() + account_badge_section() + diff --git a/src/enterprise.el b/src/enterprise.el index e902e90..4f143a1 100644 --- a/src/enterprise.el +++ b/src/enterprise.el @@ -189,6 +189,22 @@ fn enterprise() -> String { ) ) + let contact_block: String = el_div( + "class=\"ent-contact-block reveal\"", + el_div( + "class=\"ent-contact-card\"", + el_p("class=\"ent-contact-role\"", "Sales") + + el_a("mailto:enterprise@neurontechnologies.ai", "class=\"ent-contact-email\"", "enterprise@neurontechnologies.ai") + + el_p("class=\"ent-contact-desc\"", "Pricing, deployment options, and enterprise agreements.") + ) + + el_div( + "class=\"ent-contact-card\"", + el_p("class=\"ent-contact-role\"", "Security") + + el_a("mailto:security@neurontechnologies.ai", "class=\"ent-contact-email\"", "security@neurontechnologies.ai") + + el_p("class=\"ent-contact-desc\"", "Vulnerability disclosure, compliance review, and security documentation.") + ) + ) + let enterprise_box: String = el_div( "class=\"enterprise-box reveal\"", el_p("class=\"ent-who-label\"", "Who I work with") + @@ -203,7 +219,53 @@ fn enterprise() -> String { enterprise_inquiry_form() ) - let style_css: String = ".ent-inquiry-form { + let style_css: String = ".ent-contact-block { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 2.5rem; + } + .ent-contact-card { + padding: 1.5rem; + border: 1px solid rgba(0,82,160,.18); + background: rgba(0,82,160,.03); + display: flex; + flex-direction: column; + gap: .4rem; + } + .ent-contact-role { + font-family: var(--body); + font-size: .65rem; + font-weight: 600; + letter-spacing: .16em; + text-transform: uppercase; + color: var(--navy); + margin-bottom: .1rem; + } + .ent-contact-email { + font-family: var(--body); + font-size: .9375rem; + font-weight: 500; + color: var(--t1); + text-decoration: none; + border-bottom: 1px solid rgba(0,82,160,.25); + padding-bottom: .1rem; + transition: border-color 200ms, color 200ms; + width: fit-content; + } + .ent-contact-email:hover { color: var(--navy); border-color: var(--navy); } + .ent-contact-desc { + font-family: var(--body); + font-size: .8125rem; + font-weight: 300; + color: var(--t3); + line-height: 1.55; + margin-top: .25rem; + } + @media (max-width: 600px) { + .ent-contact-block { grid-template-columns: 1fr; } + } + .ent-inquiry-form { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; @@ -216,7 +278,7 @@ fn enterprise() -> String { el_section( "id=\"enterprise\" aria-label=\"Enterprise\"", - el_div("class=\"container\"", header + enterprise_cap_cards() + enterprise_box) + + el_div("class=\"container\"", header + enterprise_cap_cards() + contact_block + enterprise_box) + "" + el_script_src("/js/enterprise.js", true) ) diff --git a/src/js/account-dashboard.el b/src/js/account-dashboard.el index b7bdca8..af38ec1 100644 --- a/src/js/account-dashboard.el +++ b/src/js/account-dashboard.el @@ -246,11 +246,98 @@ fn main() -> Void { } } + async function loadApiKeys() { + var apiSection = document.getElementById('api-keys-section'); + if (apiSection) apiSection.style.display = ''; + try { + var sess = await sb.auth.getSession(); + var token = sess.data && sess.data.session ? sess.data.session.access_token : ''; + if (!token) return; + var r = await fetch('/api/api-keys', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({access_token: token}) + }); + var d = await r.json(); + if (!d.rows || !d.rows.length) return; + d.rows.forEach(function(row) { + var provider = row.provider; + var keyVal = row.key_value || ''; + var maskedEl = document.getElementById('apikey-masked-' + provider); + var delBtn = document.getElementById('apikey-del-' + provider); + if (keyVal) { + var klen = keyVal.length; + var masked = klen <= 10 ? '••••••••' : keyVal.slice(0, 6) + '••••' + keyVal.slice(-4); + if (maskedEl) maskedEl.textContent = masked; + if (delBtn) delBtn.style.display = ''; + } + }); + } catch (e) {} + } + + window.saveApiKey = async function(provider) { + var input = document.getElementById('apikey-input-' + provider); + var msg = document.getElementById('api-keys-msg'); + if (!input || !input.value.trim()) { + if (msg) { msg.style.display = 'block'; msg.style.color = '#c44'; msg.textContent = 'Enter a key first.'; } + return; + } + var keyVal = input.value.trim(); + var sess = await sb.auth.getSession(); + var token = sess.data && sess.data.session ? sess.data.session.access_token : ''; + if (!token) return; + try { + var r = await fetch('/api/api-keys/save', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({access_token: token, provider: provider, key: keyVal}) + }); + var d = await r.json(); + if (d.ok) { + var maskedEl = document.getElementById('apikey-masked-' + provider); + var delBtn = document.getElementById('apikey-del-' + provider); + if (maskedEl) maskedEl.textContent = d.masked; + if (delBtn) delBtn.style.display = ''; + input.value = ''; + if (msg) { msg.style.display = 'block'; msg.style.color = 'var(--navy)'; msg.textContent = provider.charAt(0).toUpperCase() + provider.slice(1) + ' key saved.'; } + setTimeout(function() { if (msg) msg.style.display = 'none'; }, 3000); + } else { + if (msg) { msg.style.display = 'block'; msg.style.color = '#c44'; msg.textContent = d.error || 'Save failed.'; } + } + } catch (e) { + if (msg) { msg.style.display = 'block'; msg.style.color = '#c44'; msg.textContent = 'Network error.'; } + } + }; + + window.deleteApiKey = async function(provider) { + var sess = await sb.auth.getSession(); + var token = sess.data && sess.data.session ? sess.data.session.access_token : ''; + if (!token) return; + try { + var r = await fetch('/api/api-keys/delete', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({access_token: token, provider: provider}) + }); + var d = await r.json(); + if (d.ok) { + var maskedEl = document.getElementById('apikey-masked-' + provider); + var delBtn = document.getElementById('apikey-del-' + provider); + if (maskedEl) maskedEl.textContent = 'Not configured'; + if (delBtn) delBtn.style.display = 'none'; + var msg = document.getElementById('api-keys-msg'); + if (msg) { msg.style.display = 'block'; msg.style.color = 'var(--t3)'; msg.textContent = provider.charAt(0).toUpperCase() + provider.slice(1) + ' key removed.'; } + setTimeout(function() { if (msg) msg.style.display = 'none'; }, 3000); + } + } catch (e) {} + }; + function showDashboard(user) { hide('signin-section'); show('dashboard-section'); renderUserChip(user); loadWaitlistData(); + loadApiKeys(); } async function init() { diff --git a/src/js/chat-widget.el b/src/js/chat-widget.el index 64101fc..a1c1c46 100644 --- a/src/js/chat-widget.el +++ b/src/js/chat-widget.el @@ -295,11 +295,11 @@ fn main() -> Void { session.messages.push({ role: 'ai', text: reply }); saveSession(session); } else { - addMsg('ai', 'Hey. What\'s on your mind?', true); + addMsg('ai', \"Hey. What's on your mind?\", true); } }) .catch(function() { - addMsg('ai', 'Hey. What\'s on your mind?', true); + addMsg('ai', \"Hey. What's on your mind?\", true); }); } @@ -532,7 +532,7 @@ fn main() -> Void { var ss = secsLeft % 60; var pad = function(n) { return n < 10 ? '0' + n : '' + n; }; var ts = hh > 0 ? (hh + ':' + pad(mm) + ':' + pad(ss)) : (pad(mm) + ':' + pad(ss)); - return 'You\'ve had 10 conversations today. Come back in ' + ts + '.'; + return \"You've had 10 conversations today. Come back in \" + ts + \".\"; }; addMsg('ai', _showRateTimer()); // Update the last ai message with a live ticker @@ -544,7 +544,7 @@ fn main() -> Void { if (lastAi) { lastAi.textContent = _showRateTimer(); } if (Math.floor(Date.now() / 1000) >= d.reset_at) { clearInterval(_timerInterval); - if (lastAi) { lastAi.textContent = 'You\'re all set — conversations reset. Say hello!'; } + if (lastAi) { lastAi.textContent = \"You're all set — conversations reset. Say hello!\"; } if (input) { input.disabled = false; input.placeholder = 'Ask me anything...'; } if (btn) { btn.disabled = false; } } diff --git a/src/main.el b/src/main.el index eadcd47..9f64c5e 100644 --- a/src/main.el +++ b/src/main.el @@ -1172,6 +1172,7 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String || str_eq(req_origin, "https://www.neurontechnologies.ai") || str_starts_with(req_origin, "http://localhost:") || str_starts_with(req_origin, "http://127.0.0.1:") + || str_starts_with(req_origin, "https://marketing-stage-") if !origin_ok { return "{\"__status__\":403,\"error\":\"forbidden\"}" } @@ -2144,6 +2145,97 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String return "{\"ok\":true}" } + // ── API key provisioning — POST /api/api-keys ──────────────────────────── + // Returns user's stored provider keys (masked) for display on /account. + // Body: { access_token: "" } + if str_eq(path, "/api/api-keys") && str_eq(method, "POST") { + let ak_jwt: String = json_get_string(body, "access_token") + if str_eq(ak_jwt, "") { + return "{\"__status__\":401,\"error\":\"missing_jwt\"}" + } + let ak_url: String = state_get("__supabase_project_url__") + let ak_anon: String = state_get("__supabase_anon_key__") + let ak_service: String = state_get("__supabase_service_key__") + if str_eq(ak_url, "") { + return "{\"__status__\":503,\"error\":\"supabase_not_configured\"}" + } + let ak_user: String = supabase_auth_user(ak_url, ak_anon, ak_jwt) + let ak_uid: String = json_get(ak_user, "id") + if str_eq(ak_uid, "") { + return "{\"__status__\":401,\"error\":\"invalid_jwt\"}" + } + let ak_q: String = "user_api_keys?select=provider,key_value&user_id=eq." + ak_uid + let ak_rows: String = supabase_get(ak_url, ak_service, ak_q) + return "{\"rows\":" + ak_rows + "}" + } + + // ── API key provisioning — POST /api/api-keys/save ─────────────────────── + // Upserts a single provider key for the authenticated user. + // Body: { access_token: "", provider: "openai"|"anthropic"|"gemini"|"grok", key: "" } + if str_eq(path, "/api/api-keys/save") { + let aks_jwt: String = json_get_string(body, "access_token") + let aks_provider: String = json_get_string(body, "provider") + let aks_key: String = json_get_string(body, "key") + if str_eq(aks_jwt, "") { + return "{\"__status__\":401,\"error\":\"missing_jwt\"}" + } + if str_eq(aks_provider, "") || str_eq(aks_key, "") { + return "{\"__status__\":400,\"error\":\"missing_provider_or_key\"}" + } + let aks_valid_provider: Bool = str_eq(aks_provider, "openai") + || str_eq(aks_provider, "anthropic") + || str_eq(aks_provider, "gemini") + || str_eq(aks_provider, "grok") + if !aks_valid_provider { + return "{\"__status__\":400,\"error\":\"invalid_provider\"}" + } + let aks_url: String = state_get("__supabase_project_url__") + let aks_anon: String = state_get("__supabase_anon_key__") + let aks_service: String = state_get("__supabase_service_key__") + if str_eq(aks_url, "") { + return "{\"__status__\":503,\"error\":\"supabase_not_configured\"}" + } + let aks_user: String = supabase_auth_user(aks_url, aks_anon, aks_jwt) + let aks_uid: String = json_get(aks_user, "id") + if str_eq(aks_uid, "") { + return "{\"__status__\":401,\"error\":\"invalid_jwt\"}" + } + let aks_row: String = "{\"user_id\":\"" + aks_uid + "\",\"provider\":\"" + aks_provider + "\",\"key_value\":\"" + aks_key + "\",\"updated_at\":\"now()\"}" + let _aks_resp: String = supabase_insert(aks_url, aks_service, "user_api_keys?on_conflict=user_id,provider", aks_row) + let aks_klen: Int = str_len(aks_key) + let aks_masked: String = if aks_klen <= 10 { + str_repeat("•", aks_klen) + } else { + str_slice(aks_key, 0, 6) + "••••" + str_slice(aks_key, aks_klen - 4, aks_klen) + } + return "{\"ok\":true,\"masked\":\"" + aks_masked + "\"}" + } + + // ── API key provisioning — POST /api/api-keys/delete ───────────────────── + // Soft-deletes a provider key by clearing key_value for the authenticated user. + // Body: { access_token: "", provider: "openai"|"anthropic"|"gemini"|"grok" } + if str_eq(path, "/api/api-keys/delete") { + let akd_jwt: String = json_get_string(body, "access_token") + let akd_provider: String = json_get_string(body, "provider") + if str_eq(akd_jwt, "") { + return "{\"__status__\":401,\"error\":\"missing_jwt\"}" + } + let akd_url: String = state_get("__supabase_project_url__") + let akd_anon: String = state_get("__supabase_anon_key__") + let akd_service: String = state_get("__supabase_service_key__") + if str_eq(akd_url, "") { + return "{\"__status__\":503,\"error\":\"supabase_not_configured\"}" + } + let akd_user: String = supabase_auth_user(akd_url, akd_anon, akd_jwt) + let akd_uid: String = json_get(akd_user, "id") + if str_eq(akd_uid, "") { + return "{\"__status__\":401,\"error\":\"invalid_jwt\"}" + } + let akd_row: String = "{\"user_id\":\"" + akd_uid + "\",\"provider\":\"" + akd_provider + "\",\"key_value\":\"\",\"updated_at\":\"now()\"}" + let _akd_resp: String = supabase_insert(akd_url, akd_service, "user_api_keys?on_conflict=user_id,provider", akd_row) + return "{\"ok\":true}" + } + // ── Fallback ────────────────────────────────────────────────────────────── return "{\"__status__\":404,\"error\":\"not found\"}" }