fix: move API keys to .env.local, remove localStorage dual system

Single source of truth: OPENAI_API_KEY in .env.local. Deleted the
apiKeyService class/singleton that stored keys in localStorage.
All server code reads process.env directly. FirstRunModal and
Settings now show .env.local instructions instead of key input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-02-06 11:46:41 +11:00
co-authored by Claude Opus 4.6
parent 49ff3678a3
commit 3b04a1bad0
9 changed files with 121 additions and 411 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { checkDatabaseHealth } from '@/services/database'; import { checkDatabaseHealth } from '@/services/database';
import { hasValidOpenAiKey } from '@/services/database/descriptionService'; import { hasValidOpenAiKey } from '@/services/storage/apiKeys';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
+34 -112
View File
@@ -2,49 +2,19 @@
import { useState, useEffect, type CSSProperties } from 'react'; import { useState, useEffect, type CSSProperties } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { apiKeyService } from '@/services/storage/apiKeys'; import { isFirstRun, markFirstRunComplete } from '@/services/storage/apiKeys';
export default function FirstRunModal() { export default function FirstRunModal() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [apiKey, setApiKey] = useState('');
const [status, setStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => { useEffect(() => {
// Check if this is first run if (isFirstRun()) {
if (apiKeyService.isFirstRun()) {
setIsOpen(true); setIsOpen(true);
} }
}, []); }, []);
const handleSaveKey = async () => { const handleClose = () => {
if (!apiKey.trim()) return; markFirstRunComplete();
setStatus('testing');
setErrorMessage('');
try {
apiKeyService.setOpenAiKey(apiKey.trim());
const ok = await apiKeyService.testOpenAiConnection();
if (ok) {
setStatus('success');
setTimeout(() => {
apiKeyService.markFirstRunComplete();
setIsOpen(false);
}, 1000);
} else {
setStatus('error');
setErrorMessage('Could not connect to OpenAI. Please check your key.');
}
} catch (error) {
setStatus('error');
setErrorMessage('Invalid API key format. Keys start with sk-');
}
};
const handleSkip = () => {
apiKeyService.markFirstRunComplete();
setIsOpen(false); setIsOpen(false);
}; };
@@ -53,54 +23,28 @@ export default function FirstRunModal() {
return createPortal( return createPortal(
<div style={overlayStyle}> <div style={overlayStyle}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}> <div style={modalStyle} onClick={(e) => e.stopPropagation()}>
{/* Content */}
<div style={contentStyle}> <div style={contentStyle}>
<div style={sectionStyle}> <div style={sectionStyle}>
<p style={sectionDescStyle}> <p style={sectionDescStyle}>
To use automated features (embeddings, auto-organise, smart descriptions), To use AI features (embeddings, auto-descriptions, smart tagging),
you'll need an OpenAI API key. add your OpenAI API key to <code style={codeStyle}>.env.local</code>:
</p> </p>
<div style={codeBlockStyle}>
<code>OPENAI_API_KEY=sk-your-key-here</code>
</div>
<p style={costNoteStyle}> <p style={costNoteStyle}>
Average cost for heavy use is less than $0.10 per day. Then restart the app. Average cost for heavy use is less than $0.10/day.
</p> </p>
</div> </div>
<div style={inputSectionStyle}>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
style={inputStyle}
autoFocus
/>
{errorMessage && <div style={errorStyle}>{errorMessage}</div>}
{status === 'success' && (
<div style={successStyle}>Connected successfully!</div>
)}
</div>
<div style={buttonSectionStyle}> <div style={buttonSectionStyle}>
<button <button onClick={handleClose} style={primaryButtonStyle}>
onClick={handleSaveKey} Got it
disabled={!apiKey.trim() || status === 'testing'}
style={{
...primaryButtonStyle,
opacity: apiKey.trim() && status !== 'testing' ? 1 : 0.5,
}}
>
{status === 'testing' ? 'Testing...' : 'Save & Continue'}
</button>
<button onClick={handleSkip} style={skipButtonStyle}>
Skip for now
</button> </button>
</div> </div>
<div style={noteStyle}> <div style={noteStyle}>
<p> <p>Without a key, you can still create and organise nodes manually.</p>
You can add or change your key later in Settings → API Keys.
</p>
<p style={{ marginTop: 8 }}> <p style={{ marginTop: 8 }}>
<a <a
href="https://platform.openai.com/api-keys" href="https://platform.openai.com/api-keys"
@@ -153,43 +97,33 @@ const sectionDescStyle: CSSProperties = {
lineHeight: 1.5, lineHeight: 1.5,
}; };
const codeStyle: CSSProperties = {
background: 'rgba(255, 255, 255, 0.08)',
padding: '2px 6px',
borderRadius: 4,
fontSize: 13,
fontFamily: 'monospace',
color: '#22c55e',
};
const codeBlockStyle: CSSProperties = {
background: 'rgba(0, 0, 0, 0.4)',
border: '1px solid #333',
borderRadius: 8,
padding: '12px 14px',
fontSize: 13,
fontFamily: 'monospace',
color: '#e5e7eb',
marginBottom: 12,
overflowX: 'auto',
};
const costNoteStyle: CSSProperties = { const costNoteStyle: CSSProperties = {
fontSize: 13, fontSize: 13,
color: '#6b7280', color: '#6b7280',
}; };
const inputSectionStyle: CSSProperties = {
marginBottom: 20,
};
const inputStyle: CSSProperties = {
width: '100%',
padding: '12px 14px',
fontSize: 14,
fontFamily: 'monospace',
background: 'rgba(0, 0, 0, 0.4)',
border: '1px solid #333',
borderRadius: 8,
color: '#fff',
outline: 'none',
};
const errorStyle: CSSProperties = {
marginTop: 8,
fontSize: 12,
color: '#ef4444',
};
const successStyle: CSSProperties = {
marginTop: 8,
fontSize: 12,
color: '#22c55e',
};
const buttonSectionStyle: CSSProperties = { const buttonSectionStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 12,
marginBottom: 20, marginBottom: 20,
}; };
@@ -205,18 +139,6 @@ const primaryButtonStyle: CSSProperties = {
cursor: 'pointer', cursor: 'pointer',
}; };
const skipButtonStyle: CSSProperties = {
width: '100%',
padding: '12px 16px',
fontSize: 14,
fontWeight: 500,
background: 'transparent',
color: '#6b7280',
border: '1px solid #333',
borderRadius: 8,
cursor: 'pointer',
};
const noteStyle: CSSProperties = { const noteStyle: CSSProperties = {
fontSize: 12, fontSize: 12,
color: '#6b7280', color: '#6b7280',
+47 -124
View File
@@ -1,50 +1,20 @@
"use client"; "use client";
import { useState, useEffect, type CSSProperties } from 'react'; import { useState, useEffect, type CSSProperties } from 'react';
import { apiKeyService, ApiKeyStatus } from '@/services/storage/apiKeys';
export default function ApiKeysViewer() { export default function ApiKeysViewer() {
const [openaiKey, setOpenaiKey] = useState(''); const [status, setStatus] = useState<'checking' | 'configured' | 'not-set'>('checking');
const [status, setStatus] = useState<ApiKeyStatus>({ openai: 'not-set' });
const [showKey, setShowKey] = useState(false);
useEffect(() => { useEffect(() => {
if (apiKeyService.hasOpenAiKey()) { // Check via health endpoint (server-side check of process.env)
setOpenaiKey(apiKeyService.getMaskedOpenAiKey()); fetch('/api/health')
} .then(res => res.json())
setStatus(apiKeyService.getStatus()); .then(data => {
setStatus(data.aiFeatures?.startsWith('enabled') ? 'configured' : 'not-set');
})
.catch(() => setStatus('not-set'));
}, []); }, []);
const handleSave = async () => {
if (!openaiKey.trim() || openaiKey.includes('•')) return;
try {
apiKeyService.setOpenAiKey(openaiKey.trim());
setStatus({ openai: 'testing' });
const ok = await apiKeyService.testOpenAiConnection();
setStatus({ openai: ok ? 'connected' : 'failed' });
if (ok) {
setOpenaiKey(apiKeyService.getMaskedOpenAiKey());
}
} catch (error) {
setStatus({ openai: 'failed' });
}
};
const handleClear = () => {
apiKeyService.clearOpenAiKey();
setOpenaiKey('');
setStatus({ openai: 'not-set' });
};
const getStatusLabel = (s: string) => {
if (s === 'connected') return { text: 'Connected', color: '#22c55e' };
if (s === 'failed') return { text: 'Failed', color: '#ef4444' };
if (s === 'testing') return { text: 'Testing...', color: '#6b7280' };
return { text: 'Not configured', color: '#6b7280' };
};
const statusInfo = getStatusLabel(status.openai);
return ( return (
<div style={containerStyle}> <div style={containerStyle}>
{/* Features explanation */} {/* Features explanation */}
@@ -56,57 +26,34 @@ export default function ApiKeysViewer() {
<li>Semantic search via embeddings</li> <li>Semantic search via embeddings</li>
</ul> </ul>
<div style={noteStyle}> <div style={noteStyle}>
Without a key, you can still create and organize nodes manually. Without a key, you can still create and organise nodes manually.
</div> </div>
</div> </div>
{/* Key input */} {/* Status */}
<div style={cardStyle}> <div style={cardStyle}>
<div style={cardHeaderStyle}> <div style={cardHeaderStyle}>
<span style={cardTitleStyle}>OpenAI API Key</span> <span style={cardTitleStyle}>OpenAI API Key</span>
<span style={{ fontSize: 12, color: statusInfo.color }}> <span style={{
{statusInfo.text} fontSize: 12,
color: status === 'configured' ? '#22c55e' :
status === 'checking' ? '#6b7280' : '#ef4444'
}}>
{status === 'configured' ? 'Configured' :
status === 'checking' ? 'Checking...' : 'Not configured'}
</span> </span>
</div> </div>
<input <div style={instructionsStyle}>
type={showKey ? 'text' : 'password'} <p style={{ margin: 0, marginBottom: 8 }}>
value={openaiKey} Add your key to <code style={codeInlineStyle}>.env.local</code> in the project root:
onChange={(e) => setOpenaiKey(e.target.value)} </p>
placeholder="sk-..." <div style={codeBlockStyle}>
style={inputStyle} <code>OPENAI_API_KEY=sk-your-key-here</code>
/> </div>
<p style={{ margin: 0, fontSize: 12, color: '#6b7280' }}>
<div style={buttonRowStyle}> Restart the app after changing the key.
<button </p>
onClick={handleSave}
disabled={!openaiKey.trim() || openaiKey.includes('•')}
style={{
...btnPrimaryStyle,
opacity: openaiKey.trim() && !openaiKey.includes('•') ? 1 : 0.4,
}}
>
Save & Test
</button>
<button
onClick={handleClear}
disabled={status.openai === 'not-set'}
style={{
...btnSecondaryStyle,
opacity: status.openai !== 'not-set' ? 1 : 0.4,
}}
>
Clear
</button>
<label style={toggleStyle}>
<input
type="checkbox"
checked={showKey}
onChange={(e) => setShowKey(e.target.checked)}
style={{ marginRight: 6 }}
/>
Show
</label>
</div> </div>
</div> </div>
@@ -182,54 +129,30 @@ const cardTitleStyle: CSSProperties = {
color: '#e5e7eb', color: '#e5e7eb',
}; };
const inputStyle: CSSProperties = { const instructionsStyle: CSSProperties = {
width: '100%', fontSize: 13,
color: '#d1d5db',
lineHeight: 1.5,
};
const codeInlineStyle: CSSProperties = {
background: 'rgba(255, 255, 255, 0.08)',
padding: '2px 6px',
borderRadius: 4,
fontSize: 12,
fontFamily: 'monospace',
color: '#22c55e',
};
const codeBlockStyle: CSSProperties = {
background: 'rgba(0, 0, 0, 0.4)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: 6,
padding: '10px 12px', padding: '10px 12px',
fontSize: 13, fontSize: 13,
fontFamily: 'monospace', fontFamily: 'monospace',
background: 'rgba(0, 0, 0, 0.3)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: 6,
color: '#e5e7eb', color: '#e5e7eb',
marginBottom: 12, marginBottom: 8,
outline: 'none',
};
const buttonRowStyle: CSSProperties = {
display: 'flex',
gap: 8,
alignItems: 'center',
};
const btnPrimaryStyle: CSSProperties = {
padding: '8px 14px',
fontSize: 12,
fontWeight: 500,
background: '#22c55e',
color: '#052e16',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
};
const btnSecondaryStyle: CSSProperties = {
padding: '8px 14px',
fontSize: 12,
fontWeight: 500,
background: 'rgba(255, 255, 255, 0.06)',
color: '#9ca3af',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
};
const toggleStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
fontSize: 12,
color: '#6b7280',
cursor: 'pointer',
marginLeft: 'auto',
}; };
const helpStyle: CSSProperties = { const helpStyle: CSSProperties = {
+1 -12
View File
@@ -9,8 +9,6 @@ import DatabaseViewer from './DatabaseViewer';
import ExternalAgentsPanel from './ExternalAgentsPanel'; import ExternalAgentsPanel from './ExternalAgentsPanel';
import ContextViewer from './ContextViewer'; import ContextViewer from './ContextViewer';
import GuidesViewer from './GuidesViewer'; import GuidesViewer from './GuidesViewer';
import { apiKeyService } from '@/services/storage/apiKeys';
export type SettingsTab = export type SettingsTab =
| 'logs' | 'logs'
| 'tools' | 'tools'
@@ -29,16 +27,7 @@ interface SettingsModalProps {
type TabType = SettingsTab; type TabType = SettingsTab;
export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProps) { export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProps) {
// Default to API Keys tab if no keys are configured, otherwise logs const [activeTab, setActiveTab] = useState<TabType>('logs');
const getDefaultTab = (): TabType => {
if (typeof window !== 'undefined') {
const hasKey = apiKeyService.getOpenAiKey();
return hasKey ? 'logs' : 'apikeys';
}
return 'logs';
};
const [activeTab, setActiveTab] = useState<TabType>(getDefaultTab());
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
+3 -10
View File
@@ -1,5 +1,6 @@
import { openai as openaiProvider } from '@ai-sdk/openai'; import { openai as openaiProvider } from '@ai-sdk/openai';
import { generateText } from 'ai'; import { generateText } from 'ai';
import { hasValidOpenAiKey } from '../storage/apiKeys';
export interface DescriptionInput { export interface DescriptionInput {
title: string; title: string;
@@ -15,16 +16,8 @@ export interface DescriptionInput {
dimensions?: string[]; dimensions?: string[];
} }
/** // Re-export for backwards compatibility — canonical source is ../storage/apiKeys
* Check if we have a valid OpenAI API key configured. export { hasValidOpenAiKey } from '../storage/apiKeys';
* Checks both environment variable and validates format.
*/
export function hasValidOpenAiKey(): boolean {
const key = process.env.OPENAI_API_KEY;
if (!key || key === 'your-openai-api-key-here') return false;
// Valid OpenAI keys start with sk- or sk-proj-
return key.startsWith('sk-') && key.length > 20;
}
/** /**
* Generate a simple fallback description without AI. * Generate a simple fallback description without AI.
+1 -1
View File
@@ -1,7 +1,7 @@
import { getSQLiteClient } from './sqlite-client'; import { getSQLiteClient } from './sqlite-client';
import { openai as openaiProvider } from '@ai-sdk/openai'; import { openai as openaiProvider } from '@ai-sdk/openai';
import { generateText } from 'ai'; import { generateText } from 'ai';
import { hasValidOpenAiKey } from './descriptionService'; import { hasValidOpenAiKey } from '../storage/apiKeys';
export interface Dimension { export interface Dimension {
name: string; name: string;
+2 -3
View File
@@ -2,7 +2,6 @@ import { getSQLiteClient } from './sqlite-client';
import { Edge, EdgeContext, EdgeData, EdgeCreatedVia, NodeConnection, Node } from '@/types/database'; import { Edge, EdgeContext, EdgeData, EdgeCreatedVia, NodeConnection, Node } from '@/types/database';
import { eventBroadcaster } from '../events'; import { eventBroadcaster } from '../events';
import { nodeService } from './nodes'; import { nodeService } from './nodes';
import { apiKeyService } from '../storage/apiKeys';
import { generateText } from 'ai'; import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai'; import { createOpenAI } from '@ai-sdk/openai';
import { z } from 'zod'; import { z } from 'zod';
@@ -54,7 +53,7 @@ async function inferEdgeContext(params: {
// If no API key is configured, degrade gracefully. // If no API key is configured, degrade gracefully.
// We still enforce explanation, but fall back to "related_to" classification. // We still enforce explanation, but fall back to "related_to" classification.
const apiKey = apiKeyService.getOpenAiKey(); const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) { if (!apiKey) {
return { type: 'related_to', confidence: 0.0, swap_direction: false }; return { type: 'related_to', confidence: 0.0, swap_direction: false };
} }
@@ -119,7 +118,7 @@ async function autoInferEdge(params: {
}): Promise<{ explanation: string; type: EdgeContext['type']; confidence: number; swap_direction: boolean }> { }): Promise<{ explanation: string; type: EdgeContext['type']; confidence: number; swap_direction: boolean }> {
const { fromNode, toNode } = params; const { fromNode, toNode } = params;
const apiKey = apiKeyService.getOpenAiKey(); const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) { if (!apiKey) {
// Fallback without AI // Fallback without AI
return { return {
+2 -4
View File
@@ -1,11 +1,9 @@
import OpenAI from 'openai'; import OpenAI from 'openai';
import { apiKeyService } from './storage/apiKeys';
// Initialize OpenAI client with dynamic API key support
function getOpenAiClient(): OpenAI { function getOpenAiClient(): OpenAI {
const apiKey = apiKeyService.getOpenAiKey(); const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) { if (!apiKey) {
throw new Error('OpenAI API key required. Please:\n1. Click the Settings icon (⚙️) in the bottom left\n2. Go to API Keys tab\n3. Add your OpenAI API key\n\nGet your key at: https://platform.openai.com/api-keys'); throw new Error('OpenAI API key not configured. Add OPENAI_API_KEY to your .env.local file.');
} }
return new OpenAI({ apiKey }); return new OpenAI({ apiKey });
} }
+29 -143
View File
@@ -1,148 +1,34 @@
// API Key Storage Service // API Key helpers
// Handles storage and retrieval of OpenAI API key // Single source of truth: .env.local → process.env.OPENAI_API_KEY
export interface ApiKeyStatus { /**
openai: 'connected' | 'failed' | 'testing' | 'not-set'; * Check if a valid OpenAI API key is configured in .env.local
*/
export function hasValidOpenAiKey(): boolean {
const key = process.env.OPENAI_API_KEY;
if (!key || key === 'your-openai-api-key-here') return false;
return key.startsWith('sk-') && key.length > 20;
} }
const STORAGE_KEY = 'ra-h-api-keys'; /**
const FIRST_RUN_KEY = 'ra-h-first-run-complete'; * Get the OpenAI API key from environment
*/
export class ApiKeyService { export function getOpenAiKey(): string | undefined {
private static instance: ApiKeyService; const key = process.env.OPENAI_API_KEY;
private openaiKey: string | undefined; if (!key || key === 'your-openai-api-key-here') return undefined;
private status: ApiKeyStatus = { return key;
openai: 'not-set',
};
static getInstance(): ApiKeyService {
if (!ApiKeyService.instance) {
ApiKeyService.instance = new ApiKeyService();
}
return ApiKeyService.instance;
}
constructor() {
this.loadKeys();
}
// Load keys from localStorage
private loadKeys(): void {
try {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
this.openaiKey = parsed.openai;
}
}
} catch (error) {
console.warn('Failed to load API keys from storage:', error);
this.openaiKey = undefined;
}
}
// Save keys to localStorage
private saveKeys(): void {
try {
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ openai: this.openaiKey }));
}
} catch (error) {
console.error('Failed to save API keys to storage:', error);
}
}
// Get OpenAI API key (user key or fallback to env)
getOpenAiKey(): string | undefined {
// Priority: User key > Environment key
return this.openaiKey || process.env.OPENAI_API_KEY;
}
// Set OpenAI API key
setOpenAiKey(key: string): void {
if (this.validateOpenAiKey(key)) {
this.openaiKey = key;
this.saveKeys();
} else {
throw new Error('Invalid OpenAI API key format');
}
}
// Clear OpenAI key
clearOpenAiKey(): void {
this.openaiKey = undefined;
this.saveKeys();
this.status.openai = 'not-set';
}
// Get masked key for display (show only last 4 characters)
getMaskedOpenAiKey(): string {
if (!this.openaiKey) return '';
return '••••••••••••••••••••' + this.openaiKey.slice(-4);
}
// Check if user has provided a key
hasOpenAiKey(): boolean {
return !!this.openaiKey;
}
// Validate OpenAI key format
private validateOpenAiKey(key: string): boolean {
return (
typeof key === 'string' &&
key.length > 20 &&
(key.startsWith('sk-') || key.startsWith('sk-proj-'))
);
}
// Test connection to OpenAI
async testOpenAiConnection(key?: string): Promise<boolean> {
const testKey = key || this.getOpenAiKey();
if (!testKey) return false;
this.status.openai = 'testing';
try {
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
Authorization: `Bearer ${testKey}`,
'Content-Type': 'application/json',
},
});
const isConnected = response.ok;
this.status.openai = isConnected ? 'connected' : 'failed';
return isConnected;
} catch (error) {
console.error('OpenAI connection test failed:', error);
this.status.openai = 'failed';
return false;
}
}
// Get connection status
getStatus(): ApiKeyStatus {
return { ...this.status };
}
// Update status
updateStatus(status: ApiKeyStatus['openai']): void {
this.status.openai = status;
}
// First-run tracking
isFirstRun(): boolean {
if (typeof window === 'undefined') return false;
return !localStorage.getItem(FIRST_RUN_KEY);
}
markFirstRunComplete(): void {
if (typeof window !== 'undefined') {
localStorage.setItem(FIRST_RUN_KEY, 'true');
}
}
} }
// Export singleton instance /**
export const apiKeyService = ApiKeyService.getInstance(); * Check if first run (no API key configured)
*/
export function isFirstRun(): boolean {
if (typeof window === 'undefined') return false;
return !localStorage.getItem('ra-h-first-run-complete');
}
export function markFirstRunComplete(): void {
if (typeof window !== 'undefined') {
localStorage.setItem('ra-h-first-run-complete', 'true');
}
}