Refactor to flat APIKey model with quota, admin UI, .env config, and Berlin timezone

- Remove User/Quota models; quota fields now live directly on APIKey
- Admin UI: login, API key management, settings (Ollama URL/model), proxy info display
- .env/.env.example: ADMIN_PASSWORD, PROXY_HOST/PORT, DATABASE_URL, APP_TZ
- Admin API runs on 127.0.0.1 only; proxy host/port configurable
- API keys support optional expires_at; verified against Europe/Berlin timezone
- Daily/monthly quota resets use Europe/Berlin midnight boundary
- Fix all tests to use new flat model; add expiry tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Oliver Hofmann
2026-04-28 08:21:42 +02:00
co-authored by Claude Sonnet 4.6
parent cfa874a4c3
commit c8235ec274
15 changed files with 824 additions and 471 deletions
+260 -50
View File
@@ -1,66 +1,256 @@
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom/client';
import axios from 'axios';
import './styles.css';
const maskKey = (key) => `••••••••${key.slice(-4)}`;
function App() {
const [users, setUsers] = useState([]);
const [apiKeys, setApiKeys] = useState([]);
const [loading, setLoading] = useState(true);
function authHeaders(token) {
return { Authorization: `Bearer ${token}` };
}
function Login({ onLogin }) {
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
useEffect(() => {
Promise.all([fetchUsers(), fetchApiKeys()]).finally(() => setLoading(false));
}, []);
const fetchUsers = async () => {
const handleSubmit = async (e) => {
e.preventDefault();
setError(null);
try {
const res = await axios.get('/api/users');
setUsers(res.data);
} catch (err) {
setError('Benutzer konnten nicht geladen werden.');
await axios.get('/api/api-keys', { headers: authHeaders(password) });
sessionStorage.setItem('admin_password', password);
onLogin(password);
} catch {
setError('Ungültiges Passwort.');
}
};
const fetchApiKeys = async () => {
try {
const res = await axios.get('/api/api-keys');
setApiKeys(res.data);
} catch (err) {
setError('API-Keys konnten nicht geladen werden.');
}
};
if (loading) return <div>Loading...</div>;
if (error) return <div className="error">{error}</div>;
return (
<div className="container">
<h1>Ollama Proxy Admin</h1>
<form onSubmit={handleSubmit} className="login-form">
<label>Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Admin-Passwort eingeben"
autoFocus
/>
{error && <div className="error">{error}</div>}
<button type="submit">Anmelden</button>
</form>
</div>
);
}
const EMPTY_KEY_FORM = {
name: '', expires_at: '', daily_tokens: '', monthly_tokens: '', daily_requests: '', monthly_requests: '',
};
function SettingsSection({ password }) {
const [settings, setSettings] = useState(null);
const [availableModels, setAvailableModels] = useState([]);
const [proxyEndpoint, setProxyEndpoint] = useState(null);
const [saved, setSaved] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const headers = authHeaders(password);
Promise.all([
axios.get('/api/settings', { headers }),
axios.get('/api/ollama-models', { headers }),
axios.get('/api/proxy-info', { headers }),
]).then(([settingsRes, modelsRes, proxyRes]) => {
setSettings(settingsRes.data);
setAvailableModels(modelsRes.data.models);
setProxyEndpoint(proxyRes.data.endpoint);
}).catch(() => setError('Einstellungen konnten nicht geladen werden.'));
}, []);
const handleSave = async (e) => {
e.preventDefault();
setError(null);
setSaved(false);
try {
await axios.put('/api/settings', settings, { headers: authHeaders(password) });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch {
setError('Fehler beim Speichern.');
}
};
if (!settings) return <div>Laden...</div>;
return (
<section>
<h2>Einstellungen</h2>
<form onSubmit={handleSave} className="settings-form">
<div className="settings-row">
<label>Proxy-Endpunkt</label>
<span className="settings-value">
{proxyEndpoint ?? '…'}
<small> (Änderung erfordert Neustart)</small>
</span>
</div>
<div className="settings-row">
<label>Ollama-Endpunkt</label>
<input
type="url"
value={settings.ollama_url}
onChange={(e) => setSettings({ ...settings, ollama_url: e.target.value })}
placeholder="http://localhost:11434"
required
/>
</div>
<div className="settings-row">
<label>Standard-Modell</label>
{availableModels.length > 0 ? (
<select
value={settings.default_model}
onChange={(e) => setSettings({ ...settings, default_model: e.target.value })}
>
{availableModels.map(m => <option key={m} value={m}>{m}</option>)}
</select>
) : (
<input
type="text"
value={settings.default_model}
onChange={(e) => setSettings({ ...settings, default_model: e.target.value })}
placeholder="llama3"
required
/>
)}
</div>
{error && <div className="error">{error}</div>}
{saved && <div className="success">Gespeichert.</div>}
<button type="submit">Speichern</button>
</form>
</section>
);
}
function App() {
const [password, setPassword] = useState(() => sessionStorage.getItem('admin_password'));
const [apiKeys, setApiKeys] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [newKey, setNewKey] = useState(null);
const [form, setForm] = useState(EMPTY_KEY_FORM);
const [creating, setCreating] = useState(false);
useEffect(() => {
if (!password) { setLoading(false); return; }
fetchApiKeys().finally(() => setLoading(false));
}, [password]);
const fetchApiKeys = async () => {
try {
const res = await axios.get('/api/api-keys', { headers: authHeaders(password) });
setApiKeys(res.data);
} catch {
setError('API-Keys konnten nicht geladen werden.');
}
};
const handleCreate = async (e) => {
e.preventDefault();
setCreating(true);
try {
const payload = { name: form.name };
if (form.expires_at) payload.expires_at = new Date(form.expires_at).toISOString();
if (form.daily_tokens) payload.daily_tokens = Number(form.daily_tokens);
if (form.monthly_tokens) payload.monthly_tokens = Number(form.monthly_tokens);
if (form.daily_requests) payload.daily_requests = Number(form.daily_requests);
if (form.monthly_requests) payload.monthly_requests = Number(form.monthly_requests);
const res = await axios.post('/api/api-keys', payload, { headers: authHeaders(password) });
setNewKey(res.data.plaintext_key);
setForm(EMPTY_KEY_FORM);
await fetchApiKeys();
} catch {
setError('Fehler beim Erstellen des API-Keys.');
} finally {
setCreating(false);
}
};
const handleDeactivate = async (id) => {
try {
await axios.put(`/api/api-keys/${id}/deactivate`, {}, { headers: authHeaders(password) });
await fetchApiKeys();
} catch {
setError('Fehler beim Deaktivieren.');
}
};
const logout = () => {
sessionStorage.removeItem('admin_password');
setPassword(null);
};
if (!password) return <Login onLogin={setPassword} />;
if (loading) return <div>Laden...</div>;
if (error) return <div className="error">{error}</div>;
return (
<div className="container">
<div className="header">
<h1>Ollama Proxy Admin</h1>
<button onClick={logout}>Abmelden</button>
</div>
<SettingsSection password={password} />
<section>
<h2>Users</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.username}</td>
<td>{user.email}</td>
<td>{user.is_active ? 'Active' : 'Inactive'}</td>
</tr>
))}
</tbody>
</table>
<h2>Neuer API-Key</h2>
<form onSubmit={handleCreate} className="create-form">
<input
placeholder="Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
/>
<input
type="date"
placeholder="Ablaufdatum (leer = unbegrenzt)"
value={form.expires_at}
onChange={(e) => setForm({ ...form, expires_at: e.target.value })}
/>
<input
type="number"
placeholder="Tokens/Tag (leer = unbegrenzt)"
value={form.daily_tokens}
onChange={(e) => setForm({ ...form, daily_tokens: e.target.value })}
/>
<input
type="number"
placeholder="Tokens/Monat"
value={form.monthly_tokens}
onChange={(e) => setForm({ ...form, monthly_tokens: e.target.value })}
/>
<input
type="number"
placeholder="Requests/Tag"
value={form.daily_requests}
onChange={(e) => setForm({ ...form, daily_requests: e.target.value })}
/>
<input
type="number"
placeholder="Requests/Monat"
value={form.monthly_requests}
onChange={(e) => setForm({ ...form, monthly_requests: e.target.value })}
/>
<button type="submit" disabled={creating}>Erstellen</button>
</form>
{newKey && (
<div className="new-key-box">
<strong>Neuer Key (nur einmal sichtbar):</strong>
<code>{newKey}</code>
<button onClick={() => setNewKey(null)}>✕</button>
</div>
)}
</section>
<section>
@@ -71,8 +261,13 @@ function App() {
<th>ID</th>
<th>Name</th>
<th>Key</th>
<th>User</th>
<th>Status</th>
<th>Läuft ab</th>
<th>Tokens/Tag</th>
<th>Tokens/Monat</th>
<th>Req/Tag</th>
<th>Req/Monat</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -81,8 +276,19 @@ function App() {
<td>{key.id}</td>
<td>{key.name}</td>
<td>{maskKey(key.key)}</td>
<td>{key.user_id}</td>
<td>{key.is_active ? 'Active' : 'Inactive'}</td>
<td>{key.is_active ? 'Aktiv' : 'Inaktiv'}</td>
<td>{key.expires_at ? new Date(key.expires_at).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) : '∞'}</td>
<td>{key.daily_tokens ?? '∞'}</td>
<td>{key.monthly_tokens ?? '∞'}</td>
<td>{key.daily_requests ?? '∞'}</td>
<td>{key.monthly_requests ?? '∞'}</td>
<td>
{key.is_active && (
<button className="btn-danger" onClick={() => handleDeactivate(key.id)}>
Deaktivieren
</button>
)}
</td>
</tr>
))}
</tbody>
@@ -92,4 +298,8 @@ function App() {
);
}
export default App;
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+170 -5
View File
@@ -44,12 +44,177 @@ tr:hover {
background: #f8f9fa;
}
.status-active {
color: #27ae60;
font-weight: 600;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40px;
}
.status-inactive {
.header h1 {
margin-bottom: 0;
}
.login-form {
max-width: 360px;
margin: 80px auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.login-form input {
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.login-form button, .header button {
padding: 10px 20px;
background: #2c3e50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.login-form button:hover, .header button:hover {
background: #34495e;
}
.error {
color: #e74c3c;
font-weight: 600;
font-size: 14px;
}
.create-form {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-end;
}
.create-form input {
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
flex: 1 1 160px;
}
.create-form button {
padding: 8px 20px;
background: #27ae60;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.create-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.new-key-box {
margin-top: 16px;
padding: 12px 16px;
background: #eafaf1;
border: 1px solid #27ae60;
border-radius: 4px;
display: flex;
align-items: center;
gap: 12px;
}
.new-key-box code {
flex: 1;
font-size: 13px;
word-break: break-all;
}
.new-key-box button {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #555;
}
.btn-danger {
padding: 4px 10px;
background: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.btn-danger:hover {
background: #c0392b;
}
.settings-form {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 500px;
}
.settings-row {
display: flex;
align-items: center;
gap: 12px;
}
.settings-row label {
width: 160px;
flex-shrink: 0;
font-weight: 500;
color: #2c3e50;
}
.settings-row input, .settings-row select {
flex: 1;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.settings-form button {
align-self: flex-start;
padding: 8px 20px;
background: #2c3e50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.settings-form button:hover {
background: #34495e;
}
.success {
color: #27ae60;
font-size: 14px;
}
.settings-value {
flex: 1;
font-size: 14px;
color: #2c3e50;
}
.settings-value small {
margin-left: 8px;
color: #999;
font-size: 12px;
}
+4
View File
@@ -5,6 +5,10 @@ export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api/api-keys': 'http://localhost:8001',
'/api/settings': 'http://localhost:8001',
'/api/ollama-models': 'http://localhost:8001',
'/api/proxy-info': 'http://localhost:8001',
'/api': 'http://localhost:8000',
},
},