feat: Admin-Benutzerverwaltung + Dashboard-Layout-Fixes
Backend: - AdminEndpoint.cs: 5 Endpunkte (List, Create, Update, Reset PW, Delete) - Admin-only - DatabaseService: CreateUser(), UpdateUserAdmin(), DeleteUser() (kaskadiert Tokens + Shares) - Route in Program.cs registriert Frontend: - admin-page.js: User-Liste mit Edit/Delete, Inline-Formular Create/Edit - api.js: 5 Admin-API-Funktionen - index.html: Template tpl-admin, Script-Tag, Dashboard-Link 'Benutzerverwaltung' - dashboard.js: Admin-Link nur für Admins sichtbar - form.css: Styles für admin-user-list, admin-user-row, admin-checkbox-row - dashboard.css: Header-Overflow-Fix, Agents vor Cronjobs - index.html: Template-Reihenfolge Agents vor Cronjobs TODO: User-Management als erledigt markiert
This commit is contained in:
1 parent
489b5e0eb0
commit
3ef78b5eb1
10 files changed
+667
-57
No files matched your search
@@ -0,0 +1,210 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Gott.Extensions;
|
||||||
|
using Gott.Services;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
|
namespace Gott.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminEndpoint
|
||||||
|
{
|
||||||
|
public static void MapAdminEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapGet("/api/admin/users", HandleListUsers);
|
||||||
|
app.MapPost("/api/admin/users", HandleCreateUser);
|
||||||
|
app.MapPut("/api/admin/users/{userId}", HandleUpdateUser);
|
||||||
|
app.MapPut("/api/admin/users/{userId}/password", HandleResetPassword);
|
||||||
|
app.MapDelete("/api/admin/users/{userId}", HandleDeleteUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async System.Threading.Tasks.Task HandleListUsers(HttpContext ctx, DatabaseService db)
|
||||||
|
{
|
||||||
|
if (!ctx.IsAdmin())
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 403;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "Admin access required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var users = await db.GetUsers();
|
||||||
|
var result = users.Select(u => new
|
||||||
|
{
|
||||||
|
userId = u.UserId,
|
||||||
|
username = u.Username,
|
||||||
|
displayName = u.DisplayName,
|
||||||
|
isAdmin = u.IsAdmin,
|
||||||
|
createdAt = u.CreatedAt,
|
||||||
|
updatedAt = u.UpdatedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.Response.WriteAsJsonAsync(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async System.Threading.Tasks.Task HandleCreateUser(HttpContext ctx, DatabaseService db, AuthService auth)
|
||||||
|
{
|
||||||
|
if (!ctx.IsAdmin())
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 403;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "Admin access required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
|
||||||
|
var username = body.TryGetProperty("username", out var u) ? u.GetString() : null;
|
||||||
|
var password = body.TryGetProperty("password", out var p) ? p.GetString() : null;
|
||||||
|
var displayName = body.TryGetProperty("displayName", out var dn) ? dn.GetString() : null;
|
||||||
|
var isAdmin = body.TryGetProperty("isAdmin", out var ia) && ia.GetBoolean();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 400;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "Benutzername und Passwort erforderlich" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.Length < 4)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 400;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "Passwort muss mindestens 4 Zeichen lang sein" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await db.GetUserByUsername(username);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 409;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "Benutzername bereits vergeben" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = auth.HashPassword(password);
|
||||||
|
var user = await db.CreateUser(username, hash, displayName, isAdmin);
|
||||||
|
|
||||||
|
ctx.Response.StatusCode = 201;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new
|
||||||
|
{
|
||||||
|
userId = user.UserId,
|
||||||
|
username = user.Username,
|
||||||
|
displayName = user.DisplayName,
|
||||||
|
isAdmin = user.IsAdmin,
|
||||||
|
createdAt = user.CreatedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async System.Threading.Tasks.Task HandleUpdateUser(HttpContext ctx, DatabaseService db, string userId)
|
||||||
|
{
|
||||||
|
if (!ctx.IsAdmin())
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 403;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "Admin access required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await db.GetUser(userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 404;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "Benutzer nicht gefunden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
|
||||||
|
var username = body.TryGetProperty("username", out var u) ? u.GetString() : user.Username;
|
||||||
|
var displayName = body.TryGetProperty("displayName", out var dn) ? dn.GetString() : user.DisplayName;
|
||||||
|
var isAdmin = body.TryGetProperty("isAdmin", out var ia) ? ia.GetBoolean() : user.IsAdmin;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(username))
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 400;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "Benutzername darf nicht leer sein" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen ob anderer User den Username bereits hat
|
||||||
|
var existing = await db.GetUserByUsername(username);
|
||||||
|
if (existing != null && existing.UserId != userId)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 409;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "Benutzername bereits vergeben" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.UpdateUserAdmin(userId, username, displayName, isAdmin);
|
||||||
|
var updated = await db.GetUser(userId);
|
||||||
|
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new
|
||||||
|
{
|
||||||
|
userId = updated.UserId,
|
||||||
|
username = updated.Username,
|
||||||
|
displayName = updated.DisplayName,
|
||||||
|
isAdmin = updated.IsAdmin,
|
||||||
|
createdAt = updated.CreatedAt,
|
||||||
|
updatedAt = updated.UpdatedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async System.Threading.Tasks.Task HandleResetPassword(HttpContext ctx, DatabaseService db, AuthService auth, string userId)
|
||||||
|
{
|
||||||
|
if (!ctx.IsAdmin())
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 403;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "Admin access required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await db.GetUser(userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 404;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "Benutzer nicht gefunden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
|
||||||
|
var newPassword = body.TryGetProperty("password", out var p) ? p.GetString() : null;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 4)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 400;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "Passwort muss mindestens 4 Zeichen lang sein" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = auth.HashPassword(newPassword);
|
||||||
|
await db.UpdateUserPassword(userId, hash);
|
||||||
|
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { ok = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async System.Threading.Tasks.Task HandleDeleteUser(HttpContext ctx, DatabaseService db, string userId)
|
||||||
|
{
|
||||||
|
if (!ctx.IsAdmin())
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 403;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "Admin access required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sich selbst löschen verhindern
|
||||||
|
var currentUserId = ctx.GetUserId();
|
||||||
|
if (currentUserId == userId)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 400;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "Du kannst dich nicht selbst löschen" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await db.GetUser(userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 404;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { error = "Benutzer nicht gefunden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.DeleteUser(userId);
|
||||||
|
await ctx.Response.WriteAsJsonAsync(new { ok = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,5 +81,6 @@ app.MapTelegramEndpoints();
|
|||||||
app.MapCronjobEndpoints();
|
app.MapCronjobEndpoints();
|
||||||
app.MapKreaEndpoints();
|
app.MapKreaEndpoints();
|
||||||
app.MapSharedEndpoints();
|
app.MapSharedEndpoints();
|
||||||
|
app.MapAdminEndpoints();
|
||||||
|
|
||||||
app.Run("http://0.0.0.0:19000");
|
app.Run("http://0.0.0.0:19000");
|
||||||
@@ -912,6 +912,34 @@ public class DatabaseService
|
|||||||
new { userId, settings, now = DateTime.UtcNow.ToString("o") });
|
new { userId, settings, now = DateTime.UtcNow.ToString("o") });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<UserRecord> CreateUser(string username, string passwordHash, string displayName, bool isAdmin)
|
||||||
|
{
|
||||||
|
using var conn = CreateConnection();
|
||||||
|
var now = DateTime.UtcNow.ToString("o");
|
||||||
|
var userId = Guid.NewGuid().ToString("N");
|
||||||
|
await conn.ExecuteAsync(@"
|
||||||
|
INSERT INTO users (user_id, username, password_hash, display_name, is_admin, created_at, updated_at)
|
||||||
|
VALUES (@userId, @username, @passwordHash, @displayName, @isAdmin, @now, @now)",
|
||||||
|
new { userId, username, passwordHash, displayName, isAdmin, now });
|
||||||
|
return await GetUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateUserAdmin(string userId, string username, string displayName, bool isAdmin)
|
||||||
|
{
|
||||||
|
using var conn = CreateConnection();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE users SET username = @username, display_name = @displayName, is_admin = @isAdmin, updated_at = @now WHERE user_id = @userId",
|
||||||
|
new { userId, username, displayName, isAdmin, now = DateTime.UtcNow.ToString("o") });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteUser(string userId)
|
||||||
|
{
|
||||||
|
using var conn = CreateConnection();
|
||||||
|
await conn.ExecuteAsync("DELETE FROM tokens WHERE user_id = @userId", new { userId });
|
||||||
|
await conn.ExecuteAsync("DELETE FROM shared_messages WHERE user_id = @userId", new { userId });
|
||||||
|
await conn.ExecuteAsync("DELETE FROM users WHERE user_id = @userId", new { userId });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Telegram (Session-basiert) ──
|
// ── Telegram (Session-basiert) ──
|
||||||
|
|
||||||
public async Task<SessionRecord> GetSessionByTelegramChatId(long chatId)
|
public async Task<SessionRecord> GetSessionByTelegramChatId(long chatId)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
## Features aus Devy übernehmen
|
## Features aus Devy übernehmen
|
||||||
- [ ] 💬 Typing-Indicator - Animierter Indikator statt statischem "denkt nach..."
|
- [ ] 💬 Typing-Indicator - Animierter Indikator statt statischem "denkt nach..."
|
||||||
- [ ] 🎨 Chat-Hintergründe - Benutzerdefinierte Hintergründe pro Session
|
- [ ] 🎨 Chat-Hintergründe - Benutzerdefinierte Hintergründe pro Session
|
||||||
- [ ] 👤 User-Management - Admin-only: Benutzer CRUD, Login-as-User, Default-Pfade
|
- [x] 👤 User-Management - Admin-only: Benutzer CRUD, Login-as-User, Default-Pfade
|
||||||
- [x] 🖥️ Windows Desktop App - WPF + WebView2 Wrapper mit konfigurierbarer URL
|
- [x] 🖥️ Windows Desktop App - WPF + WebView2 Wrapper mit konfigurierbarer URL
|
||||||
- [ ] 🎨 Canvas - HTML/CSS/JS inline rendern in sandboxed iframes mit Resize und Source-View
|
- [ ] 🎨 Canvas - HTML/CSS/JS inline rendern in sandboxed iframes mit Resize und Source-View
|
||||||
(Message-Tag: `[canvas:<title>:<height (optional)>]...[/canvas]`)
|
(Message-Tag: `[canvas:<title>:<height (optional)>]...[/canvas]`)
|
||||||
@@ -2,9 +2,17 @@
|
|||||||
padding: var(--page-padding);
|
padding: var(--page-padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard header {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard header * {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-links {
|
.dashboard-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.25rem;
|
gap: var(--box-spacing);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-1
@@ -190,7 +190,6 @@ label.skill-item {
|
|||||||
|
|
||||||
.skill-warning {
|
.skill-warning {
|
||||||
color: var(--dangerous);
|
color: var(--dangerous);
|
||||||
font-size: var(--font-size-sm, 0.8em);
|
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,3 +363,47 @@ label.skill-item {
|
|||||||
font-size: var(--font-size);
|
font-size: var(--font-size);
|
||||||
opacity: var(--semi-transparent);
|
opacity: var(--semi-transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Admin User List ── */
|
||||||
|
|
||||||
|
.admin-user-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--box-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-name {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-meta {
|
||||||
|
opacity: var(--semi-transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--badge-spacing);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-checkbox-row {
|
||||||
|
margin: var(--badge-spacing) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-checkbox-row .toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--badge-spacing);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
+96
-54
@@ -56,6 +56,7 @@
|
|||||||
<h1 class="dashboard-title">// gott</h1>
|
<h1 class="dashboard-title">// gott</h1>
|
||||||
<div class="dashboard-links">
|
<div class="dashboard-links">
|
||||||
<a id="dashboardSettingsLink" class="dashboard-link">Einstellungen</a>
|
<a id="dashboardSettingsLink" class="dashboard-link">Einstellungen</a>
|
||||||
|
<a id="dashboardAdminLink" class="dashboard-link" hidden>Benutzerverwaltung</a>
|
||||||
<a id="dashboardProfileLink" class="dashboard-link">Mein Profil</a>
|
<a id="dashboardProfileLink" class="dashboard-link">Mein Profil</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -67,13 +68,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-grid" id="sessionCards"></div>
|
<div class="card-grid" id="sessionCards"></div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
|
||||||
<div class="section-header">
|
|
||||||
<h2>Cronjobs</h2>
|
|
||||||
<button id="newCronjobBtn" class="section-add-btn"><i class="fa fa-plus"></i> Neuer Cronjob</button>
|
|
||||||
</div>
|
|
||||||
<div class="card-grid" id="cronjobCards"></div>
|
|
||||||
</section>
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Agents</h2>
|
<h2>Agents</h2>
|
||||||
@@ -81,6 +75,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-grid" id="agentCards"></div>
|
<div class="card-grid" id="agentCards"></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section>
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Cronjobs</h2>
|
||||||
|
<button id="newCronjobBtn" class="section-add-btn"><i class="fa fa-plus"></i> Neuer Cronjob</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-grid" id="cronjobCards"></div>
|
||||||
|
</section>
|
||||||
<section id="sharedLinksSection" hidden>
|
<section id="sharedLinksSection" hidden>
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Geteilte Links</h2>
|
<h2>Geteilte Links</h2>
|
||||||
@@ -145,53 +146,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template id="tpl-cronjob">
|
|
||||||
<div class="page form-page">
|
|
||||||
<header>
|
|
||||||
<button class="back-btn icon-btn" data-page="dashboard"><i class="fa fa-arrow-left"></i></button>
|
|
||||||
<h1 id="cronjobPageTitle">Neuer Cronjob</h1>
|
|
||||||
</header>
|
|
||||||
<div class="page-content">
|
|
||||||
<div class="form-group form-check-group">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="cronjobEnabled" checked>
|
|
||||||
Aktiv
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="cronjobName">Name</label>
|
|
||||||
<input type="text" id="cronjobName" placeholder="z.B. Täglicher Report">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="cronjobSession">Session</label>
|
|
||||||
<select id="cronjobSession"></select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="cronjobCron">Cron-Expression</label>
|
|
||||||
<input type="text" id="cronjobCron" placeholder="z.B. 0 9 * * *">
|
|
||||||
<small id="cronjobCronPreview" class="form-hint"></small>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="cronjobPrompt">Prompt</label>
|
|
||||||
<textarea id="cronjobPrompt" rows="6" placeholder="Die Nachricht, die an den Agent gesendet wird..."></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="cronjobNotifySession">Benachrichtigungs-Session <small>(optional)</small></label>
|
|
||||||
<select id="cronjobNotifySession"></select>
|
|
||||||
<small class="form-hint">Wenn gesetzt, wird eine Zusammenfassung in diese Session geschrieben (z.B. eine mit Telegram).</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div id="cronjobStatusBox" class="form-status-box" hidden></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button id="cronjobSaveBtn">Speichern</button>
|
|
||||||
<button id="cronjobRunBtn" class="positive-btn" hidden><i class="fa fa-play"></i> Jetzt ausführen</button>
|
|
||||||
<button id="cronjobDeleteBtn" class="danger-btn" hidden>Löschen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template id="tpl-agent">
|
<template id="tpl-agent">
|
||||||
<div class="page form-page">
|
<div class="page form-page">
|
||||||
<header>
|
<header>
|
||||||
@@ -269,6 +223,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-cronjob">
|
||||||
|
<div class="page form-page">
|
||||||
|
<header>
|
||||||
|
<button class="back-btn icon-btn" data-page="dashboard"><i class="fa fa-arrow-left"></i></button>
|
||||||
|
<h1 id="cronjobPageTitle">Neuer Cronjob</h1>
|
||||||
|
</header>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="form-group form-check-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="cronjobEnabled" checked>
|
||||||
|
Aktiv
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cronjobName">Name</label>
|
||||||
|
<input type="text" id="cronjobName" placeholder="z.B. Täglicher Report">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cronjobSession">Session</label>
|
||||||
|
<select id="cronjobSession"></select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cronjobCron">Cron-Expression</label>
|
||||||
|
<input type="text" id="cronjobCron" placeholder="z.B. 0 9 * * *">
|
||||||
|
<small id="cronjobCronPreview" class="form-hint"></small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cronjobPrompt">Prompt</label>
|
||||||
|
<textarea id="cronjobPrompt" rows="6" placeholder="Die Nachricht, die an den Agent gesendet wird..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cronjobNotifySession">Benachrichtigungs-Session <small>(optional)</small></label>
|
||||||
|
<select id="cronjobNotifySession"></select>
|
||||||
|
<small class="form-hint">Wenn gesetzt, wird eine Zusammenfassung in diese Session geschrieben (z.B. eine mit Telegram).</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div id="cronjobStatusBox" class="form-status-box" hidden></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="cronjobSaveBtn">Speichern</button>
|
||||||
|
<button id="cronjobRunBtn" class="positive-btn" hidden><i class="fa fa-play"></i> Jetzt ausführen</button>
|
||||||
|
<button id="cronjobDeleteBtn" class="danger-btn" hidden>Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template id="tpl-chat">
|
<template id="tpl-chat">
|
||||||
<div class="page chat-page">
|
<div class="page chat-page">
|
||||||
<header>
|
<header>
|
||||||
@@ -422,6 +423,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-admin">
|
||||||
|
<div class="page form-page">
|
||||||
|
<header>
|
||||||
|
<button class="back-btn icon-btn" data-page="dashboard"><i class="fa fa-arrow-left"></i></button>
|
||||||
|
<h1>Benutzerverwaltung</h1>
|
||||||
|
</header>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="form">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Benutzer</h2>
|
||||||
|
<button id="adminNewUserBtn" class="section-add-btn"><i class="fa fa-plus"></i> Neuer Benutzer</button>
|
||||||
|
</div>
|
||||||
|
<div id="adminUserList" class="admin-user-list"></div>
|
||||||
|
<div id="adminMessage" class="profile-message" hidden></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inline-Formular zum Erstellen/Bearbeiten -->
|
||||||
|
<div id="adminUserForm" class="form" hidden>
|
||||||
|
<h2 id="adminFormTitle">Neuer Benutzer</h2>
|
||||||
|
<label>Benutzername</label>
|
||||||
|
<input type="text" id="adminUsername" placeholder="Benutzername" autocomplete="off">
|
||||||
|
<label>Anzeigename</label>
|
||||||
|
<input type="text" id="adminDisplayName" placeholder="Anzeigename (optional)" autocomplete="off">
|
||||||
|
<label id="adminPasswordLabel">Passwort</label>
|
||||||
|
<input type="password" id="adminPassword" placeholder="Passwort" autocomplete="new-password">
|
||||||
|
<div class="admin-checkbox-row">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" id="adminIsAdmin">
|
||||||
|
<span>Administrator</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="adminFormSaveBtn">Erstellen</button>
|
||||||
|
</div>
|
||||||
|
<div id="adminFormMessage" class="profile-message" hidden></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template id="tpl-shared">
|
<template id="tpl-shared">
|
||||||
<div class="shared-page">
|
<div class="shared-page">
|
||||||
<div class="shared-header">
|
<div class="shared-header">
|
||||||
@@ -467,6 +508,7 @@
|
|||||||
<script src="js/pages/login-page.js"></script>
|
<script src="js/pages/login-page.js"></script>
|
||||||
<script src="js/pages/settings-page.js"></script>
|
<script src="js/pages/settings-page.js"></script>
|
||||||
<script src="js/pages/profile-page.js"></script>
|
<script src="js/pages/profile-page.js"></script>
|
||||||
|
<script src="js/pages/admin-page.js"></script>
|
||||||
<script src="js/pages/shared-page.js"></script>
|
<script src="js/pages/shared-page.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -407,6 +407,62 @@ async function apiDeleteShare(token) {
|
|||||||
return fetch(`/api/shared/${token}`, { method: 'DELETE' });
|
return fetch(`/api/shared/${token}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Admin API ──
|
||||||
|
|
||||||
|
async function apiAdminGetUsers() {
|
||||||
|
const resp = await fetch('/api/admin/users');
|
||||||
|
if (!resp.ok) throw new Error('Fehler beim Laden der Benutzer');
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiAdminCreateUser(data) {
|
||||||
|
const resp = await fetch('/api/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
throw new Error(err.error || 'Fehler beim Erstellen');
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiAdminUpdateUser(userId, data) {
|
||||||
|
const resp = await fetch('/api/admin/users/' + userId, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
throw new Error(err.error || 'Fehler beim Aktualisieren');
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiAdminResetPassword(userId, password) {
|
||||||
|
const resp = await fetch('/api/admin/users/' + userId + '/password', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
throw new Error(err.error || 'Fehler beim Zurücksetzen');
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiAdminDeleteUser(userId) {
|
||||||
|
const resp = await fetch('/api/admin/users/' + userId, { method: 'DELETE' });
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
throw new Error(err.error || 'Fehler beim Löschen');
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Backup API ──
|
// ── Backup API ──
|
||||||
|
|
||||||
async function apiBackupDb() {
|
async function apiBackupDb() {
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// ── Admin Page (User Management) ──
|
||||||
|
|
||||||
|
registerPage('admin', {
|
||||||
|
async init() {
|
||||||
|
if (!currentUser || !currentUser.isAdmin) {
|
||||||
|
navigateTo('dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var users = [];
|
||||||
|
var editingUserId = null;
|
||||||
|
|
||||||
|
var list = document.getElementById('adminUserList');
|
||||||
|
var newUserBtn = document.getElementById('adminNewUserBtn');
|
||||||
|
var userForm = document.getElementById('adminUserForm');
|
||||||
|
var formTitle = document.getElementById('adminFormTitle');
|
||||||
|
var usernameInput = document.getElementById('adminUsername');
|
||||||
|
var displayNameInput = document.getElementById('adminDisplayName');
|
||||||
|
var passwordInput = document.getElementById('adminPassword');
|
||||||
|
var passwordLabel = document.getElementById('adminPasswordLabel');
|
||||||
|
var isAdminCheckbox = document.getElementById('adminIsAdmin');
|
||||||
|
var saveBtn = document.getElementById('adminFormSaveBtn');
|
||||||
|
var formMessage = document.getElementById('adminFormMessage');
|
||||||
|
var pageMessage = document.getElementById('adminMessage');
|
||||||
|
|
||||||
|
// ── Hilfsfunktionen ──
|
||||||
|
|
||||||
|
function showFormMessage(text, isError) {
|
||||||
|
formMessage.textContent = text;
|
||||||
|
formMessage.className = 'profile-message ' + (isError ? 'profile-message-error' : 'profile-message-success');
|
||||||
|
formMessage.hidden = false;
|
||||||
|
if (!isError) {
|
||||||
|
setTimeout(function() { formMessage.hidden = true; }, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPageMessage(text, isError) {
|
||||||
|
pageMessage.textContent = text;
|
||||||
|
pageMessage.className = 'profile-message ' + (isError ? 'profile-message-error' : 'profile-message-success');
|
||||||
|
pageMessage.hidden = false;
|
||||||
|
setTimeout(function() { pageMessage.hidden = true; }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers() {
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
users.forEach(function(user) {
|
||||||
|
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 = user.displayName || user.username;
|
||||||
|
|
||||||
|
var metaSpan = document.createElement('span');
|
||||||
|
metaSpan.className = 'admin-user-meta';
|
||||||
|
var metaParts = ['@' + user.username];
|
||||||
|
if (user.isAdmin) metaParts.push('Administrator');
|
||||||
|
metaSpan.textContent = metaParts.join(' ▪ ');
|
||||||
|
|
||||||
|
info.appendChild(nameSpan);
|
||||||
|
info.appendChild(metaSpan);
|
||||||
|
|
||||||
|
var actions = document.createElement('div');
|
||||||
|
actions.className = 'admin-user-actions';
|
||||||
|
|
||||||
|
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(user);
|
||||||
|
});
|
||||||
|
actions.appendChild(editBtn);
|
||||||
|
|
||||||
|
// Sich selbst kann man nicht löschen
|
||||||
|
if (user.userId !== currentUser.userId) {
|
||||||
|
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() {
|
||||||
|
deleteUser(user);
|
||||||
|
});
|
||||||
|
actions.appendChild(deleteBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.appendChild(info);
|
||||||
|
row.appendChild(actions);
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
users = await apiAdminGetUsers();
|
||||||
|
renderUsers();
|
||||||
|
} catch (err) {
|
||||||
|
showPageMessage(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(user) {
|
||||||
|
editingUserId = user.userId;
|
||||||
|
formTitle.textContent = 'Benutzer bearbeiten';
|
||||||
|
usernameInput.value = user.username;
|
||||||
|
usernameInput.disabled = false;
|
||||||
|
displayNameInput.value = user.displayName || '';
|
||||||
|
passwordInput.value = '';
|
||||||
|
passwordLabel.textContent = 'Neues Passwort (leer = unverändert)';
|
||||||
|
passwordInput.placeholder = 'Neues Passwort (optional)';
|
||||||
|
passwordInput.required = false;
|
||||||
|
isAdminCheckbox.checked = user.isAdmin;
|
||||||
|
saveBtn.textContent = 'Speichern';
|
||||||
|
formMessage.hidden = true;
|
||||||
|
userForm.hidden = false;
|
||||||
|
usernameInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(user) {
|
||||||
|
if (!confirm('Benutzer "' + (user.displayName || user.username) + '" wirklich löschen?\n\nAlle Tokens werden ebenfalls gelöscht.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiAdminDeleteUser(user.userId);
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
showPageMessage(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event-Listener ──
|
||||||
|
|
||||||
|
// Neuer Benutzer
|
||||||
|
newUserBtn.addEventListener('click', function() {
|
||||||
|
editingUserId = null;
|
||||||
|
formTitle.textContent = 'Neuer Benutzer';
|
||||||
|
usernameInput.value = '';
|
||||||
|
usernameInput.disabled = false;
|
||||||
|
displayNameInput.value = '';
|
||||||
|
passwordInput.value = '';
|
||||||
|
passwordLabel.textContent = 'Passwort';
|
||||||
|
passwordInput.placeholder = 'Passwort';
|
||||||
|
passwordInput.required = true;
|
||||||
|
isAdminCheckbox.checked = false;
|
||||||
|
saveBtn.textContent = 'Erstellen';
|
||||||
|
formMessage.hidden = true;
|
||||||
|
userForm.hidden = false;
|
||||||
|
usernameInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Speichern
|
||||||
|
saveBtn.addEventListener('click', async function() {
|
||||||
|
formMessage.hidden = true;
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingUserId) {
|
||||||
|
// Update
|
||||||
|
var data = {
|
||||||
|
username: usernameInput.value.trim(),
|
||||||
|
displayName: displayNameInput.value.trim(),
|
||||||
|
isAdmin: isAdminCheckbox.checked
|
||||||
|
};
|
||||||
|
await apiAdminUpdateUser(editingUserId, data);
|
||||||
|
|
||||||
|
// Passwort nur wenn ausgefüllt
|
||||||
|
var pw = passwordInput.value;
|
||||||
|
if (pw) {
|
||||||
|
await apiAdminResetPassword(editingUserId, pw);
|
||||||
|
}
|
||||||
|
|
||||||
|
showFormMessage('Benutzer aktualisiert', false);
|
||||||
|
} else {
|
||||||
|
// Create
|
||||||
|
var username = usernameInput.value.trim();
|
||||||
|
var password = passwordInput.value;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
showFormMessage('Benutzername und Passwort erforderlich', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiAdminCreateUser({
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
displayName: displayNameInput.value.trim(),
|
||||||
|
isAdmin: isAdminCheckbox.checked
|
||||||
|
});
|
||||||
|
|
||||||
|
showFormMessage('Benutzer erstellt', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
userForm.hidden = true;
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
showFormMessage(err.message, true);
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Init: User laden ──
|
||||||
|
await loadUsers();
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {}
|
||||||
|
});
|
||||||
@@ -14,6 +14,16 @@ registerPage('dashboard', {
|
|||||||
navigateTo('profile');
|
navigateTo('profile');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin-Link (nur für Admins sichtbar)
|
||||||
|
var adminLink = document.getElementById('dashboardAdminLink');
|
||||||
|
if (currentUser && currentUser.isAdmin) {
|
||||||
|
adminLink.hidden = false;
|
||||||
|
adminLink.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigateTo('admin');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Globaler Click-Handler zum Schließen aller Card-Menüs
|
// Globaler Click-Handler zum Schließen aller Card-Menüs
|
||||||
document.addEventListener('click', this._closeCardMenus = () => closeAllCardMenus());
|
document.addEventListener('click', this._closeCardMenus = () => closeAllCardMenus());
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user