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:
co-authored by
Claude Sonnet 4.6
parent
cfa874a4c3
commit
c8235ec274
+260
-50
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user