From 3ef78b5eb1cb144f120a1245fa4edf69aacc82c0 Mon Sep 17 00:00:00 2001 From: Meleki Date: Thu, 5 Mar 2026 19:19:16 +0100 Subject: [PATCH] feat: Admin-Benutzerverwaltung + Dashboard-Layout-Fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Endpoints/AdminEndpoint.cs | 210 ++++++++++++++++++++++++++++++++ Program.cs | 1 + Services/DatabaseService.cs | 28 +++++ TODO.md | 2 +- wwwroot/css/dashboard.css | 10 +- wwwroot/css/form.css | 45 ++++++- wwwroot/index.html | 150 ++++++++++++++--------- wwwroot/js/api.js | 56 +++++++++ wwwroot/js/pages/admin-page.js | 212 +++++++++++++++++++++++++++++++++ wwwroot/js/pages/dashboard.js | 10 ++ 10 files changed, 667 insertions(+), 57 deletions(-) create mode 100644 Endpoints/AdminEndpoint.cs create mode 100644 wwwroot/js/pages/admin-page.js diff --git a/Endpoints/AdminEndpoint.cs b/Endpoints/AdminEndpoint.cs new file mode 100644 index 0000000..d4d417a --- /dev/null +++ b/Endpoints/AdminEndpoint.cs @@ -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(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(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(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 }); + } +} diff --git a/Program.cs b/Program.cs index 8a353dc..8f1f2a4 100644 --- a/Program.cs +++ b/Program.cs @@ -81,5 +81,6 @@ app.MapTelegramEndpoints(); app.MapCronjobEndpoints(); app.MapKreaEndpoints(); app.MapSharedEndpoints(); +app.MapAdminEndpoints(); app.Run("http://0.0.0.0:19000"); diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index cf05fd9..576b4a6 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -912,6 +912,34 @@ public class DatabaseService new { userId, settings, now = DateTime.UtcNow.ToString("o") }); } + public async Task 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) ── public async Task GetSessionByTelegramChatId(long chatId) diff --git a/TODO.md b/TODO.md index 30f6ada..99cbda3 100644 --- a/TODO.md +++ b/TODO.md @@ -46,7 +46,7 @@ ## Features aus Devy übernehmen - [ ] 💬 Typing-Indicator - Animierter Indikator statt statischem "denkt nach..." - [ ] 🎨 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 - [ ] 🎨 Canvas - HTML/CSS/JS inline rendern in sandboxed iframes mit Resize und Source-View (Message-Tag: `[canvas::<height (optional)>]...[/canvas]`) diff --git a/wwwroot/css/dashboard.css b/wwwroot/css/dashboard.css index 7dc57f9..f3095a6 100644 --- a/wwwroot/css/dashboard.css +++ b/wwwroot/css/dashboard.css @@ -2,9 +2,17 @@ padding: var(--page-padding); } +.dashboard header { + overflow: auto; +} + +.dashboard header * { + white-space: nowrap; +} + .dashboard-links { display: flex; - gap: 1.25rem; + gap: var(--box-spacing); align-items: center; flex: 1; } diff --git a/wwwroot/css/form.css b/wwwroot/css/form.css index 02080c3..edb942d 100644 --- a/wwwroot/css/form.css +++ b/wwwroot/css/form.css @@ -190,7 +190,6 @@ label.skill-item { .skill-warning { color: var(--dangerous); - font-size: var(--font-size-sm, 0.8em); margin-left: auto; } @@ -364,3 +363,47 @@ label.skill-item { font-size: var(--font-size); 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; +} diff --git a/wwwroot/index.html b/wwwroot/index.html index 5036228..a3a5582 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -56,6 +56,7 @@ <h1 class="dashboard-title">// gott</h1> <div class="dashboard-links"> <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> </div> </header> @@ -67,13 +68,6 @@ </div> <div class="card-grid" id="sessionCards"></div> </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> <div class="section-header"> <h2>Agents</h2> @@ -81,6 +75,13 @@ </div> <div class="card-grid" id="agentCards"></div> </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> <div class="section-header"> <h2>Geteilte Links</h2> @@ -145,53 +146,6 @@ </div> </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"> <div class="page form-page"> <header> @@ -269,6 +223,53 @@ </div> </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"> <div class="page chat-page"> <header> @@ -422,6 +423,46 @@ </div> </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"> <div class="shared-page"> <div class="shared-header"> @@ -467,6 +508,7 @@ <script src="js/pages/login-page.js"></script> <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/shared-page.js"></script> <script src="js/app.js"></script> </body> diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index cc4c571..2fa036f 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -407,6 +407,62 @@ async function apiDeleteShare(token) { 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 ── async function apiBackupDb() { diff --git a/wwwroot/js/pages/admin-page.js b/wwwroot/js/pages/admin-page.js new file mode 100644 index 0000000..681f6f8 --- /dev/null +++ b/wwwroot/js/pages/admin-page.js @@ -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() {} +}); diff --git a/wwwroot/js/pages/dashboard.js b/wwwroot/js/pages/dashboard.js index f2b70bd..1666c87 100644 --- a/wwwroot/js/pages/dashboard.js +++ b/wwwroot/js/pages/dashboard.js @@ -14,6 +14,16 @@ registerPage('dashboard', { 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 document.addEventListener('click', this._closeCardMenus = () => closeAllCardMenus());