feat: API-Keys-Verwaltung in DB statt appsettings
- Neue Tabelle api_keys (Provider, Name, ApiKey, IsActive) - Admin-CRUD: GET/POST/PUT/DELETE /api/admin/api-keys - Aktivieren/Deaktivieren pro Provider (nur einer aktiv) - Live-Token-Update im AnthropicService ohne Neustart - Migration: AnthropicToken aus appsettings.Production.json → DB - AnthropicToken aus ServerSettings-Definitionen + appsettings entfernt - Frontend: API-Keys-Seite mit Erstellen/Bearbeiten/Löschen/Toggle - Dashboard: API-Keys-Link für Admins
This commit is contained in:
1 parent
1228dc9d58
commit
fa8deb15b6
11 files changed
+649
-4
No files matched your search
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Gott.Extensions;
|
||||
using Gott.Models;
|
||||
using Gott.Services;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Gott.Endpoints;
|
||||
|
||||
public static class ApiKeysEndpoint
|
||||
{
|
||||
public static void MapApiKeysEndpoints(this WebApplication app)
|
||||
{
|
||||
app.MapGet("/api/admin/api-keys", HandleGetAll);
|
||||
app.MapPost("/api/admin/api-keys", HandleCreate);
|
||||
app.MapPut("/api/admin/api-keys/{keyId}", HandleUpdate);
|
||||
app.MapPut("/api/admin/api-keys/{keyId}/activate", HandleActivate);
|
||||
app.MapPut("/api/admin/api-keys/{keyId}/deactivate", HandleDeactivate);
|
||||
app.MapDelete("/api/admin/api-keys/{keyId}", HandleDelete);
|
||||
}
|
||||
|
||||
private static async Task HandleGetAll(HttpContext ctx, DatabaseService db)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
|
||||
var keys = await db.GetApiKeys();
|
||||
var result = keys.Select(k => new
|
||||
{
|
||||
k.KeyId,
|
||||
k.Provider,
|
||||
k.Name,
|
||||
k.ApiKey,
|
||||
k.IsActive,
|
||||
k.CreatedAt,
|
||||
k.UpdatedAt
|
||||
});
|
||||
await ctx.Response.WriteAsJsonAsync(result);
|
||||
}
|
||||
|
||||
private static async Task HandleCreate(HttpContext ctx, DatabaseService db)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
|
||||
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
|
||||
var provider = body.GetProperty("provider").GetString()?.Trim().ToLower() ?? "";
|
||||
var name = body.GetProperty("name").GetString()?.Trim() ?? "";
|
||||
var apiKey = body.GetProperty("apiKey").GetString()?.Trim() ?? "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Provider, Name und API-Key sind erforderlich" });
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow.ToString("o");
|
||||
var record = new ApiKeyRecord
|
||||
{
|
||||
KeyId = Guid.NewGuid().ToString("N"),
|
||||
Provider = provider,
|
||||
Name = name,
|
||||
ApiKey = apiKey,
|
||||
IsActive = false,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await db.CreateApiKey(record);
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
record.KeyId,
|
||||
record.Provider,
|
||||
record.Name,
|
||||
record.ApiKey,
|
||||
record.IsActive,
|
||||
record.CreatedAt
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task HandleUpdate(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
|
||||
var existing = await db.GetApiKey(keyId);
|
||||
if (existing == null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" });
|
||||
return;
|
||||
}
|
||||
|
||||
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
|
||||
var name = body.TryGetProperty("name", out var n) ? n.GetString()?.Trim() : null;
|
||||
var apiKey = body.TryGetProperty("apiKey", out var k) ? k.GetString()?.Trim() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
name = existing.Name;
|
||||
|
||||
await db.UpdateApiKey(keyId, name, apiKey);
|
||||
|
||||
// Wenn aktiver Key geändert wird → Token im Service aktualisieren
|
||||
if (existing.IsActive && existing.Provider == "anthropic" && !string.IsNullOrWhiteSpace(apiKey))
|
||||
anthropic.UpdateToken(apiKey);
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new { ok = true });
|
||||
}
|
||||
|
||||
private static async Task HandleActivate(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
|
||||
var existing = await db.GetApiKey(keyId);
|
||||
if (existing == null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" });
|
||||
return;
|
||||
}
|
||||
|
||||
await db.SetActiveApiKey(keyId, existing.Provider);
|
||||
|
||||
// Token im laufenden Service aktualisieren
|
||||
if (existing.Provider == "anthropic")
|
||||
anthropic.UpdateToken(existing.ApiKey);
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new { ok = true });
|
||||
}
|
||||
|
||||
private static async Task HandleDeactivate(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
|
||||
var existing = await db.GetApiKey(keyId);
|
||||
if (existing == null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" });
|
||||
return;
|
||||
}
|
||||
|
||||
await db.DeactivateApiKey(keyId);
|
||||
|
||||
// Token im laufenden Service leeren
|
||||
if (existing.Provider == "anthropic")
|
||||
anthropic.UpdateToken("");
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new { ok = true });
|
||||
}
|
||||
|
||||
private static async Task HandleDelete(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
|
||||
var existing = await db.GetApiKey(keyId);
|
||||
if (existing == null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Wenn aktiver Key gelöscht wird → Token leeren
|
||||
if (existing.IsActive && existing.Provider == "anthropic")
|
||||
anthropic.UpdateToken("");
|
||||
|
||||
await db.DeleteApiKey(keyId);
|
||||
await ctx.Response.WriteAsJsonAsync(new { ok = true });
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -23,7 +23,6 @@ public static class ServerSettingsEndpoint
|
||||
private static readonly List<SettingDefinition> Definitions = new List<SettingDefinition>
|
||||
{
|
||||
new SettingDefinition("Port", "int", 19000, "Server-Port (erfordert Neustart)", "number"),
|
||||
new SettingDefinition("AnthropicToken", "string", "", "Anthropic API Key", "password"),
|
||||
new SettingDefinition("MaxIterations", "int", 100, "Max Tool-Loop Iterationen pro Chat", "number"),
|
||||
new SettingDefinition("MaxHistory", "int", 120, "Max History-Messages pro Request", "number"),
|
||||
new SettingDefinition("ReminderInterval", "int", 30, "System-Prompt Reminder alle N Messages (0 = deaktiviert)", "number"),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Gott.Models;
|
||||
|
||||
public class ApiKeyRecord
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string KeyId { get; set; }
|
||||
public string Provider { get; set; } // anthropic, gemini, openai, ...
|
||||
public string Name { get; set; } // Anzeigename z.B. "Tausi Pro Key"
|
||||
public string ApiKey { get; set; } // Der eigentliche Key
|
||||
public bool IsActive { get; set; } // Nur einer pro Provider aktiv
|
||||
public string CreatedAt { get; set; }
|
||||
public string UpdatedAt { get; set; }
|
||||
}
|
||||
+8
-1
@@ -53,7 +53,13 @@ builder.Services.AddSingleton(embeddingBgService);
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<Gott.Services.EmbeddingBackgroundService>());
|
||||
|
||||
// AnthropicService (Singleton – shared zwischen ChatEndpoint und TelegramService)
|
||||
var anthropicToken = builder.Configuration["AnthropicToken"] ?? "";
|
||||
// Token wird aus DB geladen (api_keys Tabelle), Fallback auf Config
|
||||
var anthropicToken = "";
|
||||
var activeKey = dbService.GetActiveApiKey("anthropic").GetAwaiter().GetResult();
|
||||
if (activeKey != null)
|
||||
anthropicToken = activeKey.ApiKey;
|
||||
else if (!string.IsNullOrWhiteSpace(builder.Configuration["AnthropicToken"]))
|
||||
anthropicToken = builder.Configuration["AnthropicToken"];
|
||||
var payloadLogging = builder.Configuration.GetValue<bool>("PayloadLogging", false);
|
||||
var payloadLoggingGzip = builder.Configuration.GetValue<bool>("PayloadLoggingGzip", false);
|
||||
builder.Services.AddSingleton<AnthropicService>(sp =>
|
||||
@@ -116,6 +122,7 @@ app.MapKreaEndpoints();
|
||||
app.MapSharedEndpoints();
|
||||
app.MapAdminEndpoints();
|
||||
app.MapServerSettingsEndpoints();
|
||||
app.MapApiKeysEndpoints();
|
||||
app.MapOpenClawEndpoints();
|
||||
app.MapRemoteEndpoints();
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace Gott.Services;
|
||||
public class AnthropicService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly string _token;
|
||||
private string _token;
|
||||
private readonly ToolService _toolService;
|
||||
private readonly DatabaseService _db;
|
||||
private readonly MemoryService _memoryService;
|
||||
@@ -54,6 +54,14 @@ public class AnthropicService
|
||||
_telegramService = telegramService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aktualisiert den API-Token zur Laufzeit (z.B. wenn ein anderer Key aktiviert wird).
|
||||
/// </summary>
|
||||
public void UpdateToken(string token)
|
||||
{
|
||||
_token = token ?? "";
|
||||
}
|
||||
|
||||
public async Task StreamChat(
|
||||
HttpResponse response,
|
||||
string modelId,
|
||||
|
||||
@@ -289,6 +289,23 @@ public class DatabaseService
|
||||
CREATE INDEX IF NOT EXISTS idx_shared_messages_message ON shared_messages(message_id);
|
||||
");
|
||||
|
||||
conn.Execute(@"
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key_id TEXT NOT NULL UNIQUE,
|
||||
provider TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_provider ON api_keys(provider);
|
||||
");
|
||||
|
||||
// Migration: AnthropicToken aus appsettings.Production.json → api_keys Tabelle
|
||||
MigrateAnthropicTokenFromConfig(conn);
|
||||
|
||||
TryAddColumn(conn, "agents", "avatar_small", "TEXT");
|
||||
TryAddColumn(conn, "agents", "is_public", "INTEGER NOT NULL DEFAULT 0");
|
||||
TryAddColumn(conn, "sessions", "image_small", "TEXT");
|
||||
@@ -1399,4 +1416,128 @@ public class DatabaseService
|
||||
WHERE c.is_enabled = 1 AND c.next_run_at IS NOT NULL AND c.next_run_at <= @now",
|
||||
new { now });
|
||||
}
|
||||
|
||||
// ── API Keys ──
|
||||
|
||||
private void MigrateAnthropicTokenFromConfig(SqliteConnection conn)
|
||||
{
|
||||
// Nur migrieren wenn noch keine Anthropic-Keys existieren
|
||||
var count = conn.ExecuteScalar<int>("SELECT COUNT(*) FROM api_keys WHERE provider = 'anthropic'");
|
||||
if (count > 0) return;
|
||||
|
||||
// Token aus appsettings.Production.json lesen
|
||||
var configPath = Path.Combine(Path.GetDirectoryName(_connectionString.Replace("Data Source=", "")) ?? "", "..", "appsettings.Production.json");
|
||||
// Normaler Pfad: neben der DB-Datei im übergeordneten Ordner
|
||||
var basePath = Path.GetDirectoryName(_connectionString.Replace("Data Source=", ""));
|
||||
if (basePath != null)
|
||||
{
|
||||
var prodPath = Path.Combine(basePath, "..", "appsettings.Production.json");
|
||||
if (File.Exists(prodPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(prodPath);
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("AnthropicToken", out var tokenEl))
|
||||
{
|
||||
var token = tokenEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
var now = DateTime.UtcNow.ToString("o");
|
||||
conn.Execute(@"
|
||||
INSERT INTO api_keys (key_id, provider, name, api_key, is_active, created_at, updated_at)
|
||||
VALUES (@keyId, 'anthropic', 'Migriert aus Config', @token, 1, @now, @now)",
|
||||
new { keyId = Guid.NewGuid().ToString("N"), token, now });
|
||||
Console.WriteLine("[Migration] AnthropicToken aus appsettings.Production.json → api_keys migriert");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* Config nicht lesbar – ignorieren */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ApiKeyRecord>> GetApiKeys()
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<ApiKeyRecord>(
|
||||
"SELECT * FROM api_keys ORDER BY provider, name");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ApiKeyRecord>> GetApiKeysByProvider(string provider)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<ApiKeyRecord>(
|
||||
"SELECT * FROM api_keys WHERE provider = @provider ORDER BY name",
|
||||
new { provider });
|
||||
}
|
||||
|
||||
public async Task<ApiKeyRecord> GetActiveApiKey(string provider)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<ApiKeyRecord>(
|
||||
"SELECT * FROM api_keys WHERE provider = @provider AND is_active = 1 LIMIT 1",
|
||||
new { provider });
|
||||
}
|
||||
|
||||
public async Task<ApiKeyRecord> GetApiKey(string keyId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<ApiKeyRecord>(
|
||||
"SELECT * FROM api_keys WHERE key_id = @keyId",
|
||||
new { keyId });
|
||||
}
|
||||
|
||||
public async Task CreateApiKey(ApiKeyRecord record)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
await conn.ExecuteAsync(@"
|
||||
INSERT INTO api_keys (key_id, provider, name, api_key, is_active, created_at, updated_at)
|
||||
VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsActive, @CreatedAt, @UpdatedAt)",
|
||||
record);
|
||||
}
|
||||
|
||||
public async Task UpdateApiKey(string keyId, string name, string apiKey = null)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
if (!string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE api_keys SET name = @name, api_key = @apiKey, updated_at = @now WHERE key_id = @keyId",
|
||||
new { keyId, name, apiKey, now = DateTime.UtcNow.ToString("o") });
|
||||
}
|
||||
else
|
||||
{
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE api_keys SET name = @name, updated_at = @now WHERE key_id = @keyId",
|
||||
new { keyId, name, now = DateTime.UtcNow.ToString("o") });
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetActiveApiKey(string keyId, string provider)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
// Erst alle deaktivieren
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE api_keys SET is_active = 0, updated_at = @now WHERE provider = @provider",
|
||||
new { provider, now = DateTime.UtcNow.ToString("o") });
|
||||
// Dann den gewählten aktivieren
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE api_keys SET is_active = 1, updated_at = @now WHERE key_id = @keyId",
|
||||
new { keyId, now = DateTime.UtcNow.ToString("o") });
|
||||
}
|
||||
|
||||
public async Task DeactivateApiKey(string keyId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE api_keys SET is_active = 0, updated_at = @now WHERE key_id = @keyId",
|
||||
new { keyId, now = DateTime.UtcNow.ToString("o") });
|
||||
}
|
||||
|
||||
public async Task DeleteApiKey(string keyId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
await conn.ExecuteAsync("DELETE FROM api_keys WHERE key_id = @keyId", new { keyId });
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -7,10 +7,10 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Port": 19000,
|
||||
"AnthropicToken": "",
|
||||
"MaxIterations": 100,
|
||||
"MaxHistory": 120,
|
||||
"ReminderInterval": 30,
|
||||
"ExecuteTimeout": 180,
|
||||
"PayloadLogging": false,
|
||||
"PayloadLoggingGzip": false,
|
||||
"KreaToken": "",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
<div class="dashboard-links">
|
||||
<a id="dashboardSettingsLink" class="dashboard-link">Einstellungen</a>
|
||||
<a id="dashboardAdminLink" class="dashboard-link" hidden>Benutzerverwaltung</a>
|
||||
<a id="dashboardApiKeysLink" class="dashboard-link" hidden>API-Keys</a>
|
||||
<a id="dashboardServerSettingsLink" class="dashboard-link" hidden>Servereinstellungen</a>
|
||||
<a id="dashboardProfileLink" class="dashboard-link">Mein Profil</a>
|
||||
</div>
|
||||
@@ -539,6 +540,39 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-api-keys">
|
||||
<div class="page form-page">
|
||||
<header>
|
||||
<button class="back-btn icon-btn" data-page="dashboard"><i class="fa fa-arrow-left"></i></button>
|
||||
<h1>API-Keys</h1>
|
||||
</header>
|
||||
<div class="page-content">
|
||||
<div class="form">
|
||||
<div class="section-header">
|
||||
<h2>API-Key</h2>
|
||||
<button id="apiKeysNewBtn" class="section-add-btn"><i class="fa fa-plus"></i> Neuer Key</button>
|
||||
</div>
|
||||
<div id="apiKeysList" class="admin-user-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="apiKeysForm" class="form" hidden>
|
||||
<h2 id="apiKeysFormTitle">Neuer API-Key</h2>
|
||||
<label>Provider</label>
|
||||
<select id="apiKeysProvider">
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
<label>Name</label>
|
||||
<input type="text" id="apiKeysName" placeholder="z.B. Mein Pro Key" autocomplete="off">
|
||||
<label>API-Key</label>
|
||||
<input type="text" id="apiKeysKey" placeholder="sk-ant-..." autocomplete="off">
|
||||
<div class="form-actions">
|
||||
<button id="apiKeysFormSaveBtn">Erstellen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-admin">
|
||||
<div class="page form-page">
|
||||
<header>
|
||||
@@ -642,6 +676,7 @@
|
||||
<script src="js/pages/settings-page.js"></script>
|
||||
<script src="js/pages/profile-page.js"></script>
|
||||
<script src="js/pages/admin-page.js"></script>
|
||||
<script src="js/pages/api-keys-page.js"></script>
|
||||
<script src="js/pages/server-settings-page.js"></script>
|
||||
<script src="js/pages/shared-page.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
|
||||
@@ -584,6 +584,67 @@ async function apiSaveServerSettings(data) {
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// ── API Keys (Admin) ──
|
||||
|
||||
async function apiGetApiKeys() {
|
||||
const resp = await fetch('/api/admin/api-keys');
|
||||
if (!resp.ok) throw new Error('Fehler beim Laden der API-Keys');
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiCreateApiKey(data) {
|
||||
const resp = await fetch('/api/admin/api-keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Fehler beim Erstellen');
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiUpdateApiKey(keyId, data) {
|
||||
const resp = await fetch('/api/admin/api-keys/' + keyId, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Fehler beim Aktualisieren');
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiActivateApiKey(keyId) {
|
||||
const resp = await fetch('/api/admin/api-keys/' + keyId + '/activate', { method: 'PUT' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Fehler beim Aktivieren');
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiDeactivateApiKey(keyId) {
|
||||
const resp = await fetch('/api/admin/api-keys/' + keyId + '/deactivate', { method: 'PUT' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Fehler beim Deaktivieren');
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiDeleteApiKey(keyId) {
|
||||
const resp = await fetch('/api/admin/api-keys/' + keyId, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Fehler beim Löschen');
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// ── Krea Image API ──
|
||||
|
||||
async function apiKreaStatus() {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// ── API Keys Page (Admin) ──
|
||||
|
||||
registerPage('api-keys', {
|
||||
async init() {
|
||||
if (!currentUser || !currentUser.isAdmin) {
|
||||
navigateTo('dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
var keys = [];
|
||||
var editingKeyId = null;
|
||||
|
||||
var list = document.getElementById('apiKeysList');
|
||||
var newBtn = document.getElementById('apiKeysNewBtn');
|
||||
var form = document.getElementById('apiKeysForm');
|
||||
var formTitle = document.getElementById('apiKeysFormTitle');
|
||||
var providerSelect = document.getElementById('apiKeysProvider');
|
||||
var nameInput = document.getElementById('apiKeysName');
|
||||
var keyInput = document.getElementById('apiKeysKey');
|
||||
var saveBtn = document.getElementById('apiKeysFormSaveBtn');
|
||||
|
||||
function renderKeys() {
|
||||
list.innerHTML = '';
|
||||
|
||||
if (keys.length === 0) {
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'admin-user-meta';
|
||||
empty.style.padding = '1rem 0';
|
||||
empty.textContent = 'Keine API-Keys konfiguriert.';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
keys.forEach(function(key) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'admin-user-row';
|
||||
|
||||
var info = document.createElement('div');
|
||||
info.className = 'admin-user-info';
|
||||
|
||||
var nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'admin-user-name';
|
||||
nameSpan.textContent = key.name;
|
||||
|
||||
var metaSpan = document.createElement('span');
|
||||
metaSpan.className = 'admin-user-meta';
|
||||
var parts = [key.provider];
|
||||
if (key.isActive) parts.push('✅ Aktiv');
|
||||
metaSpan.textContent = parts.join(' · ');
|
||||
|
||||
info.appendChild(nameSpan);
|
||||
info.appendChild(metaSpan);
|
||||
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'admin-user-actions';
|
||||
|
||||
// Bearbeiten
|
||||
var editBtn = document.createElement('button');
|
||||
editBtn.className = 'icon-btn';
|
||||
editBtn.title = 'Bearbeiten';
|
||||
editBtn.innerHTML = '<i class="fa fa-pencil"></i>';
|
||||
editBtn.addEventListener('click', function() { startEdit(key); });
|
||||
actions.appendChild(editBtn);
|
||||
|
||||
// Aktivieren/Deaktivieren
|
||||
if (key.isActive) {
|
||||
var deactivateBtn = document.createElement('button');
|
||||
deactivateBtn.className = 'icon-btn';
|
||||
deactivateBtn.title = 'Deaktivieren';
|
||||
deactivateBtn.innerHTML = '<i class="fa fa-toggle-on" style="color:var(--accent)"></i>';
|
||||
deactivateBtn.addEventListener('click', function() { deactivateKey(key); });
|
||||
actions.appendChild(deactivateBtn);
|
||||
} else {
|
||||
var activateBtn = document.createElement('button');
|
||||
activateBtn.className = 'icon-btn';
|
||||
activateBtn.title = 'Aktivieren';
|
||||
activateBtn.innerHTML = '<i class="fa fa-toggle-off"></i>';
|
||||
activateBtn.addEventListener('click', function() { activateKey(key); });
|
||||
actions.appendChild(activateBtn);
|
||||
}
|
||||
|
||||
// Löschen
|
||||
var deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'icon-btn';
|
||||
deleteBtn.title = 'Löschen';
|
||||
deleteBtn.innerHTML = '<i class="fa fa-trash"></i>';
|
||||
deleteBtn.addEventListener('click', function() { deleteKey(key); });
|
||||
actions.appendChild(deleteBtn);
|
||||
|
||||
row.appendChild(info);
|
||||
row.appendChild(actions);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadKeys() {
|
||||
try {
|
||||
keys = await apiGetApiKeys();
|
||||
renderKeys();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(key) {
|
||||
editingKeyId = key.keyId;
|
||||
formTitle.textContent = 'API-Key bearbeiten';
|
||||
providerSelect.value = key.provider;
|
||||
providerSelect.disabled = true;
|
||||
nameInput.value = key.name;
|
||||
keyInput.value = key.apiKey;
|
||||
saveBtn.textContent = 'Speichern';
|
||||
form.hidden = false;
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
async function activateKey(key) {
|
||||
try {
|
||||
await apiActivateApiKey(key.keyId);
|
||||
showToast(key.name + ' aktiviert');
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivateKey(key) {
|
||||
try {
|
||||
await apiDeactivateApiKey(key.keyId);
|
||||
showToast(key.name + ' deaktiviert');
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteKey(key) {
|
||||
if (!confirm('API-Key "' + key.name + '" wirklich löschen?')) return;
|
||||
try {
|
||||
await apiDeleteApiKey(key.keyId);
|
||||
showToast('API-Key gelöscht');
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Neuer Key
|
||||
newBtn.addEventListener('click', function() {
|
||||
editingKeyId = null;
|
||||
formTitle.textContent = 'Neuer API-Key';
|
||||
providerSelect.value = 'anthropic';
|
||||
providerSelect.disabled = false;
|
||||
nameInput.value = '';
|
||||
keyInput.value = '';
|
||||
saveBtn.textContent = 'Erstellen';
|
||||
form.hidden = false;
|
||||
nameInput.focus();
|
||||
});
|
||||
|
||||
// Speichern (Erstellen oder Bearbeiten)
|
||||
saveBtn.addEventListener('click', () => withButtonLoading(saveBtn, saveBtn.textContent, async () => {
|
||||
var name = nameInput.value.trim();
|
||||
|
||||
if (!name) {
|
||||
showToast('Name erforderlich', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingKeyId) {
|
||||
// Bearbeiten
|
||||
var data = { name: name };
|
||||
var newKey = keyInput.value.trim();
|
||||
if (newKey) data.apiKey = newKey;
|
||||
await apiUpdateApiKey(editingKeyId, data);
|
||||
showToast('API-Key aktualisiert');
|
||||
} else {
|
||||
// Erstellen
|
||||
var provider = providerSelect.value;
|
||||
var apiKey = keyInput.value.trim();
|
||||
if (!apiKey) {
|
||||
showToast('API-Key erforderlich', 'error');
|
||||
return;
|
||||
}
|
||||
await apiCreateApiKey({ provider: provider, name: name, apiKey: apiKey });
|
||||
showToast('API-Key erstellt');
|
||||
}
|
||||
form.hidden = true;
|
||||
editingKeyId = null;
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}));
|
||||
|
||||
await loadKeys();
|
||||
},
|
||||
|
||||
destroy() {}
|
||||
});
|
||||
@@ -17,12 +17,18 @@ registerPage('dashboard', {
|
||||
// Admin-Links (nur für Admins sichtbar)
|
||||
var adminLink = document.getElementById('dashboardAdminLink');
|
||||
var serverSettingsLink = document.getElementById('dashboardServerSettingsLink');
|
||||
var apiKeysLink = document.getElementById('dashboardApiKeysLink');
|
||||
if (currentUser && currentUser.isAdmin) {
|
||||
adminLink.hidden = false;
|
||||
adminLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
navigateTo('admin');
|
||||
});
|
||||
apiKeysLink.hidden = false;
|
||||
apiKeysLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
navigateTo('api-keys');
|
||||
});
|
||||
serverSettingsLink.hidden = false;
|
||||
serverSettingsLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
Reference in new issue
Block a user