diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c1695a1..2377e34 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -488,6 +488,7 @@ Alle Endpoints folgen dem **Minimal API**-Pattern mit Extension Methods (`MapXxx | **Krea** | `/api/images/` | `POST generate`, `GET list`, `GET file/{name}`, `GET file/{name}/thumb`, `GET status` | | **Skills** | `/api/skills` | `GET list`, `GET/PUT agent skills`, Output-Files + Thumbnails | | **Profile** | `/api/profile` | `GET`, `PUT`, `PUT password` | +| **Users** | `/api/users` | `GET` (minimales User-Verzeichnis: id/username/displayName – für jeden eingeloggten User, z. B. Session-Zuweisung) | | **Settings** | `/api/settings` | `GET`, `PUT` | | **Backup** | `/api/backup` | `POST` (Admin only, tar.gz Download) | | **Shared** | `/api/shared/`, `/api/messages/{id}/share` | Share erstellen, öffentlich abrufen, auflisten, löschen | diff --git a/Endpoints/ProfileEndpoint.cs b/Endpoints/ProfileEndpoint.cs index 0570170..cd6aa44 100644 --- a/Endpoints/ProfileEndpoint.cs +++ b/Endpoints/ProfileEndpoint.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Text.Json; using Gott.Extensions; using Gott.Services; @@ -14,6 +15,24 @@ public static class ProfileEndpoint app.MapGet("/api/profile", HandleGet); app.MapPut("/api/profile", HandleUpdate); app.MapPut("/api/profile/password", HandlePasswordChange); + app.MapGet("/api/users", HandleListDirectory); + } + + /// + /// Minimales User-Verzeichnis für jeden eingeloggten Nutzer (z. B. um eine Session + /// einem anderen Benutzer zuzuweisen). Gibt bewusst NUR id/username/displayName zurück – + /// keine sensiblen Felder wie defaultPath/canUseCli/isAdmin (dafür gibt es /api/admin/users). + /// + private static async System.Threading.Tasks.Task HandleListDirectory(HttpContext ctx, DatabaseService db) + { + var users = await db.GetUsers(); + var result = users.Select(u => new + { + userId = u.UserId, + username = u.Username, + displayName = u.DisplayName + }); + await ctx.Response.WriteAsJsonAsync(result); } private static async System.Threading.Tasks.Task HandleGet(HttpContext ctx, DatabaseService db) diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index 7e1ad16..a6a54ea 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -186,7 +186,23 @@ public static class SessionEndpoint } } - await db.UpdateSession(sessionId, body, imageService); + // Session einem anderen Benutzer zuweisen. Jeder Besitzer darf seine EIGENE Session + // übergeben – die Ownership-Prüfung oben (existing.UserId == userId) stellt bereits sicher, + // dass der Aufrufer der Besitzer ist. Der Ziel-User muss existieren. + if (body.TryGetProperty("userId", out var newUid) + && newUid.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(newUid.GetString())) + { + var targetUser = await db.GetUser(newUid.GetString()); + if (targetUser == null) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "Zielbenutzer nicht gefunden" }); + return; + } + } + + await db.UpdateSession(sessionId, body, imageService, allowUserReassign: true); var session = await db.GetSession(sessionId); await ctx.Response.WriteAsJsonAsync(session); diff --git a/Services/DatabaseService.Sessions.cs b/Services/DatabaseService.Sessions.cs index 52154ae..c5da5d4 100644 --- a/Services/DatabaseService.Sessions.cs +++ b/Services/DatabaseService.Sessions.cs @@ -112,7 +112,7 @@ public partial class DatabaseService new { sessionId, now = DateTime.UtcNow.ToString("o") }); } - public async Task UpdateSession(string sessionId, System.Text.Json.JsonElement body, ImageService imageService = null) + public async Task UpdateSession(string sessionId, System.Text.Json.JsonElement body, ImageService imageService = null, bool allowUserReassign = false) { using var conn = CreateConnection(); var sets = new List { "updated_at = @now" }; @@ -120,6 +120,16 @@ public partial class DatabaseService p.Add("sessionId", sessionId); p.Add("now", DateTime.UtcNow.ToString("o")); + // Session einem anderen Benutzer zuweisen – nur wenn explizit erlaubt (Admin). + // Leerer/null userId wird ignoriert (eine Session muss immer einen Besitzer haben). + if (allowUserReassign && body.TryGetProperty("userId", out var uid) + && uid.ValueKind == System.Text.Json.JsonValueKind.String + && !string.IsNullOrWhiteSpace(uid.GetString())) + { + sets.Add("user_id = @newUserId"); + p.Add("newUserId", uid.GetString()); + } + if (body.TryGetProperty("name", out var n)) { sets.Add("name = @name"); p.Add("name", n.GetString()); } if (body.TryGetProperty("description", out var d)) { sets.Add("description = @desc"); p.Add("desc", d.GetString()); } if (body.TryGetProperty("image", out var img)) diff --git a/wwwroot/index.html b/wwwroot/index.html index 97c7b74..9f6eaeb 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -174,6 +174,14 @@ Wird automatisch gesetzt wenn jemand /start an den Bot sendet. +
diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 7f7da7e..ebf2dd1 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -211,6 +211,9 @@ function apiShareMessage(id, includeContext) { return _post('/api/messages/' + i function apiGetShared(token) { return _get('/api/shared/' + token); } function apiDeleteShare(token) { return fetch('/api/shared/' + token, { method: 'DELETE' }); } +// Minimales User-Verzeichnis (jeder eingeloggte User) – z. B. um eine Session zuzuweisen. +function apiGetUsers() { return _get('/api/users'); } + // ── Admin User API ── function apiAdminGetUsers() { return _get('/api/admin/users'); } diff --git a/wwwroot/js/cards.js b/wwwroot/js/cards.js index 029f002..3e7e07f 100644 --- a/wwwroot/js/cards.js +++ b/wwwroot/js/cards.js @@ -87,6 +87,35 @@ function buildTelegramBotCard(bot) { } +// ── User Card Helper ── +// Erzeugt ein kompaktes .card-Element für einen Benutzer (für den Zuweisungs-Picker). +function buildUserCard(user) { + const card = document.createElement('div'); + card.className = 'card card-compact'; + + const badges = []; + if (user.isAdmin) { + badges.push(' Admin'); + } + + const metaParts = []; + metaParts.push(` ${esc(user.username)}`); + + const title = user.displayName || user.username; + card.innerHTML = ` +
+
+ ${esc(title)} + ${badges.join('')} +
+ ${metaParts.length ? `
${metaParts.join('')}
` : ''} +
+ `; + + return card; +} + + // ── Agent Card Helper ── // Erzeugt ein .card-Element für einen Agent (wiederverwendbar für Dashboard + Picker). diff --git a/wwwroot/js/pages/session-page.js b/wwwroot/js/pages/session-page.js index 76bab02..0930ee5 100644 --- a/wwwroot/js/pages/session-page.js +++ b/wwwroot/js/pages/session-page.js @@ -37,6 +37,9 @@ registerPage('session', { const addCommandBtn = document.getElementById('addCommandBtn'); const unityMcpUrlEl = document.getElementById('sessionUnityMcpUrl'); const unityMcpTokenEl = document.getElementById('sessionUnityMcpToken'); + const ownerRow = document.getElementById('sessionOwnerRow'); + const userSelectCard = document.getElementById('sessionUserSelectCard'); + const userSelect = document.getElementById('sessionUserSelect'); const more = document.querySelector('.more'); // Avatar Editor initialisieren @@ -123,6 +126,11 @@ registerPage('session', { updateReviewVisibility(); setupAgentPicker(finalizerAgentSelectCard, finalizerAgentSelect, session && session.finalizerAgentId, 'fa-flag-checkered', 'Kein Finalizer-Agent'); + // User-Zuweisung (jeder Besitzer, nur für bestehende Sessions) + if (!isNew) { + initSessionUserPicker(ownerRow, userSelectCard, userSelect, session && session.userId); + } + initMoreButton(more); // Workspace Browse @@ -179,6 +187,16 @@ registerPage('session', { unityMcpToken: unityMcpTokenEl.value.trim() || null }; + // Session einem anderen Benutzer zuweisen. Wechselt der Besitzer tatsächlich, + // erst rückfragen – die Session verschwindet danach aus der eigenen Übersicht. + if (!isNew && userSelect.value && userSelect.value !== (session && session.userId)) { + var targetLabel = userSelect.dataset.userLabel || 'einem anderen Benutzer'; + if (!confirm('Diese Session an "' + targetLabel + '" zuweisen? Sie verschwindet danach aus deiner Übersicht.')) { + return; + } + data.userId = userSelect.value; + } + if (isNew) { await apiCreateSession(data); } else { @@ -284,4 +302,53 @@ async function initTelegramBotPicker(cardEl, selectEl, currentBotId) { }); } +// Session einem anderen Benutzer zuweisen (jeder Besitzer seiner eigenen Session). +async function initSessionUserPicker(rowEl, cardEl, selectEl, currentOwnerId) { + var users = []; + try { + var result = await apiGetUsers(); + if (Array.isArray(result)) users = result; + } catch (e) { + // Fehler beim Laden → Zuweisung bleibt versteckt + return; + } + var userMap = {}; + for (var i = 0; i < users.length; i++) userMap[users[i].userId] = users[i]; + + function userLabel(user) { + return user ? (user.displayName || user.username || '') : ''; + } + + function renderUserInCard(user) { + var inner = user ? buildUserCard(user) : null; + _renderCardPreview(cardEl, inner, 'fa-user', 'Kein Benutzer', 'agent-select-change'); + selectEl.dataset.userLabel = userLabel(user); + } + + selectEl.value = currentOwnerId || ''; + renderUserInCard(userMap[currentOwnerId] || null); + rowEl.hidden = false; + + cardEl.addEventListener('click', function() { + openUserPickerModal(users, selectEl.value, function(selectedUserId) { + // "Keine Änderung" (null) ignorieren – eine Session muss immer einen Besitzer haben. + if (!selectedUserId) return; + selectEl.value = selectedUserId; + renderUserInCard(userMap[selectedUserId] || null); + }); + }); +} + +function openUserPickerModal(users, currentId, onSelect) { + var items = users.map(function(u) { + u._pickerId = u.userId; + return u; + }); + _openPickerModal('Benutzer auswählen', items, currentId, onSelect, { + noneLabel: 'Keine Änderung', + noneIcon: 'fa-user', + buildCard: function(u) { return buildUserCard(u); } + }); +} +