Add API key authentication to all endpoints

This commit is contained in:
Your Name 2026-05-19 20:19:02 -04:00
parent 56d8fb4d56
commit 176439c1df
4 changed files with 207 additions and 28 deletions

View file

@ -3,6 +3,36 @@ const { useState: useStateApp, useEffect: useEffectApp, useRef: useRefApp } = Re
const API_BASE = '';
// API key storage
const API_KEY_STORAGE_KEY = 'clio_api_key';
function getStoredApiKey() {
return localStorage.getItem(API_KEY_STORAGE_KEY);
}
function setStoredApiKey(key) {
localStorage.setItem(API_KEY_STORAGE_KEY, key);
}
function clearStoredApiKey() {
localStorage.removeItem(API_KEY_STORAGE_KEY);
}
// Authenticated fetch wrapper
async function authFetch(url, options = {}) {
const apiKey = getStoredApiKey();
const headers = {
...options.headers,
'X-API-Key': apiKey || '',
};
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
clearStoredApiKey();
window.dispatchEvent(new Event('auth-required'));
}
return response;
}
// Parse hash into route info
function parseHash() {
const hash = window.location.hash.slice(1) || '/dashboard';
@ -70,14 +100,110 @@ function transformRecording(rec) {
};
}
function ApiKeyPrompt({ onSubmit }) {
const [key, setKey] = useStateApp('');
const [error, setError] = useStateApp(null);
const [checking, setChecking] = useStateApp(false);
async function handleSubmit(e) {
e.preventDefault();
if (!key.trim()) return;
setChecking(true);
setError(null);
try {
// Test the key by making a simple request
const res = await fetch(`${API_BASE}/api/v1/sources`, {
headers: { 'X-API-Key': key.trim() }
});
if (res.status === 401) {
setError('Invalid API key');
setChecking(false);
return;
}
if (!res.ok) {
setError('Server error');
setChecking(false);
return;
}
setStoredApiKey(key.trim());
onSubmit();
} catch (err) {
setError('Connection failed');
setChecking(false);
}
}
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', background: 'var(--bg)',
}}>
<form onSubmit={handleSubmit} style={{
background: 'var(--bg2)', padding: 32, borderRadius: 8,
border: '1px solid var(--border)', width: 320,
}}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 14, marginBottom: 20, color: 'var(--text)' }}>
Enter API Key
</div>
<input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="API Key"
autoFocus
style={{
width: '100%', padding: '10px 12px', marginBottom: 12,
background: 'var(--bg3)', border: '1px solid var(--border)',
borderRadius: 4, color: 'var(--text)', fontFamily: 'var(--font-mono)',
fontSize: 13, outline: 'none',
}}
/>
{error && (
<div style={{ color: 'var(--red)', fontSize: 12, marginBottom: 12, fontFamily: 'var(--font-mono)' }}>
{error}
</div>
)}
<button
type="submit"
disabled={checking}
style={{
width: '100%', padding: '10px 12px',
background: 'var(--amber)', color: 'var(--bg)',
border: 'none', borderRadius: 4, fontFamily: 'var(--font-mono)',
fontSize: 12, fontWeight: 600, cursor: 'pointer',
opacity: checking ? 0.6 : 1,
}}
>
{checking ? 'Checking...' : 'Continue'}
</button>
</form>
</div>
);
}
function App() {
const [data, setData] = useStateApp({ recordings: [], highlights: [], devices: {} });
const [loading, setLoading] = useStateApp(true);
const [error, setError] = useStateApp(null);
const [needsAuth, setNeedsAuth] = useStateApp(!getStoredApiKey());
// Lift highlights state so adding/removing works across views
const [highlights, setHighlights] = useStateApp([]);
// Listen for auth-required events (401 responses)
useEffectApp(() => {
function handleAuthRequired() {
setNeedsAuth(true);
}
window.addEventListener('auth-required', handleAuthRequired);
return () => window.removeEventListener('auth-required', handleAuthRequired);
}, []);
// Initialize from URL hash
const initialRoute = parseHash();
const [view, setViewState] = useStateApp(initialRoute.view);
@ -146,7 +272,7 @@ function App() {
async function fetchRecordingDetail(recordingId) {
setLoadingDetail(true);
try {
const res = await fetch(`${API_BASE}/api/v1/recordings/${recordingId}`);
const res = await authFetch(`${API_BASE}/api/v1/recordings/${recordingId}`);
if (!res.ok) throw new Error('Failed to fetch recording');
const rec = await res.json();
const transformed = transformRecording(rec);
@ -189,9 +315,9 @@ function App() {
async function fetchData() {
try {
const [recordingsRes, sourcesRes, highlightsRes] = await Promise.all([
fetch(`${API_BASE}/api/v1/recordings?limit=1000`),
fetch(`${API_BASE}/api/v1/sources`),
fetch(`${API_BASE}/api/v1/highlights?limit=1000`),
authFetch(`${API_BASE}/api/v1/recordings?limit=1000`),
authFetch(`${API_BASE}/api/v1/sources`),
authFetch(`${API_BASE}/api/v1/highlights?limit=1000`),
]);
if (!recordingsRes.ok) throw new Error('Failed to fetch recordings');
@ -256,6 +382,19 @@ function App() {
fetchData();
}, []);
// Show API key prompt if not authenticated
if (needsAuth) {
return (
<ApiKeyPrompt onSubmit={() => {
setNeedsAuth(false);
setLoading(true);
// Re-trigger data fetch by incrementing a counter or similar
// For simplicity, just reload the page
window.location.reload();
}} />
);
}
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', color: 'var(--text2)' }}>
@ -363,5 +502,8 @@ function App() {
);
}
// Export utilities for other components
Object.assign(window, { authFetch, getStoredApiKey });
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

View file

@ -12,6 +12,8 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
const [showAddHighlight, setShowAddHighlight] = useStateRD(false);
const [newHighlightTag, setNewHighlightTag] = useStateRD('important');
const [newHighlightNote, setNewHighlightNote] = useStateRD('');
const [audioBlobUrl, setAudioBlobUrl] = useStateRD(null);
const [audioLoading, setAudioLoading] = useStateRD(true);
const audioRef = useRefRD(null);
const transcriptRef = useRefRD(null);
@ -20,8 +22,35 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
const totalSeconds = audioDuration || metadataDuration || 1; // avoid division by zero
const recHighlights = allHighlights.filter(h => h.recordingId === r.id);
// Audio URL from API
const audioUrl = `${API_BASE}/api/v1/recordings/${r.id}/audio`;
// Fetch audio with auth and create blob URL
useEffectRD(() => {
let cancelled = false;
let blobUrl = null;
async function fetchAudio() {
setAudioLoading(true);
try {
const res = await window.authFetch(`${API_BASE}/api/v1/recordings/${r.id}/audio`);
if (cancelled) return;
if (!res.ok) throw new Error('Failed to fetch audio');
const blob = await res.blob();
if (cancelled) return;
blobUrl = URL.createObjectURL(blob);
setAudioBlobUrl(blobUrl);
} catch (err) {
console.error('Failed to load audio:', err);
} finally {
if (!cancelled) setAudioLoading(false);
}
}
fetchAudio();
return () => {
cancelled = true;
if (blobUrl) URL.revokeObjectURL(blobUrl);
};
}, [r.id]);
// Track recording ID to detect when recording changes
const lastRecordingId = useRefRD(null);
@ -33,6 +62,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
hasAutoPlayed.current = false;
lastRecordingId.current = r.id;
setAudioDuration(null); // Reset to use metadata until audio loads
setAudioBlobUrl(null); // Clear old blob URL
}
}, [r.id]);
@ -190,7 +220,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
const endSecs = highlightEndTime; // null for point highlight
try {
const res = await fetch(`${API_BASE}/api/v1/highlights`, {
const res = await window.authFetch(`${API_BASE}/api/v1/highlights`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -230,7 +260,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
const numericId = hid.startsWith('h-') ? hid.slice(2) : hid;
try {
const res = await fetch(`${API_BASE}/api/v1/highlights/${numericId}`, {
const res = await window.authFetch(`${API_BASE}/api/v1/highlights/${numericId}`, {
method: 'DELETE',
});
if (!res.ok && res.status !== 404) {
@ -444,7 +474,8 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
</div>
{/* Hidden audio element */}
<audio ref={audioRef} src={audioUrl} preload="metadata" />
{audioBlobUrl && <audio ref={audioRef} src={audioBlobUrl} preload="metadata" />}
{audioLoading && <div style={{ color: 'var(--text3)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>Loading audio...</div>}
{/* Controls */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>