Let session owners reassign their session to another user

Previously a session could only be reassigned to another user via a
manual DB UPDATE. This adds it to the session edit UI for every owner of
a session (not just admins) - the ownership check in HandleUpdate
(existing.UserId == userId) already guarantees the caller owns the
session, so handing over one's own session is safe.

Backend:
- ProfileEndpoint: new GET /api/users returning a minimal user directory
  (userId/username/displayName only) for any logged-in user. The
  admin-gated /api/admin/users stays the source for sensitive fields
  (defaultPath/canUseCli/isAdmin).
- SessionEndpoint.HandleUpdate: validate the target user exists (400 if
  not) and pass allowUserReassign: true to UpdateSession.
- DatabaseService.UpdateSession: new allowUserReassign flag; only then is
  a non-empty userId applied as user_id (empty/null is ignored - a
  session must always have an owner).

Frontend:
- index.html: hidden #sessionOwnerRow with a user picker card in the
  session edit form (existing sessions only).
- cards.js: buildUserCard helper for the picker.
- session-page.js: initSessionUserPicker loads users via apiGetUsers(),
  preselects the current owner, and on an actual owner change asks for
  confirmation (irreversible - the session leaves the user's own list).
- api.js: apiGetUsers() -> /api/users.
- ARCHITECTURE.md: document /api/users.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.8 committed 2026-06-13 11:52:53 +02:00
1 parent 483aa471a1
commit b23eba32b4
8 files changed
+155 -2

No files matched your search

+1
View File
@@ -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 |
+19
View File
@@ -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);
}
/// <summary>
/// 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).
/// </summary>
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)
+17 -1
View File
@@ -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);
+11 -1
View File
@@ -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<string> { "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))
+8
View File
@@ -174,6 +174,14 @@
<button type="button" id="sessionTelegramUnlink" class="icon-btn danger-btn" title="Verknüpfung lösen"><i class="fa fa-chain-broken"></i></button>
</div>
<small class="form-hint">Wird automatisch gesetzt wenn jemand /start an den Bot sendet.</small>
<div id="sessionOwnerRow" hidden>
<label>Zugewiesener Benutzer</label>
<div id="sessionUserSelectCard" class="agent-select-card">
<div class="agent-select-empty"><i class="fa fa-user"></i> Kein Benutzer</div>
</div>
<input type="hidden" id="sessionUserSelect" value="">
<small class="form-hint">Weist diese Session einem anderen Benutzer zu. Nach dem Speichern verschwindet sie aus deiner Übersicht.</small>
</div>
</div>
</div>
<div class="form-actions">
+3
View File
@@ -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'); }
+29
View File
@@ -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('<span class="badge badge-feature"><i class="fa fa-shield"></i> Admin</span>');
}
const metaParts = [];
metaParts.push(`<span class="card-meta-item"><i class="fa fa-at"></i> ${esc(user.username)}</span>`);
const title = user.displayName || user.username;
card.innerHTML = `
<div class="card-body">
<div class="card-title-row">
<span class="card-title"><i class="fa fa-user"></i> ${esc(title)}</span>
${badges.join('')}
</div>
${metaParts.length ? `<div class="card-meta">${metaParts.join('')}</div>` : ''}
</div>
`;
return card;
}
// ── Agent Card Helper ──
// Erzeugt ein .card-Element für einen Agent (wiederverwendbar für Dashboard + Picker).
+67
View File
@@ -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); }
});
}