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:
1 parent
483aa471a1
commit
b23eba32b4
8 files changed
+155
-2
No files matched your search
@@ -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` |
|
| **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 |
|
| **Skills** | `/api/skills` | `GET list`, `GET/PUT agent skills`, Output-Files + Thumbnails |
|
||||||
| **Profile** | `/api/profile` | `GET`, `PUT`, `PUT password` |
|
| **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` |
|
| **Settings** | `/api/settings` | `GET`, `PUT` |
|
||||||
| **Backup** | `/api/backup` | `POST` (Admin only, tar.gz Download) |
|
| **Backup** | `/api/backup` | `POST` (Admin only, tar.gz Download) |
|
||||||
| **Shared** | `/api/shared/`, `/api/messages/{id}/share` | Share erstellen, öffentlich abrufen, auflisten, löschen |
|
| **Shared** | `/api/shared/`, `/api/messages/{id}/share` | Share erstellen, öffentlich abrufen, auflisten, löschen |
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Gott.Extensions;
|
using Gott.Extensions;
|
||||||
using Gott.Services;
|
using Gott.Services;
|
||||||
@@ -14,6 +15,24 @@ public static class ProfileEndpoint
|
|||||||
app.MapGet("/api/profile", HandleGet);
|
app.MapGet("/api/profile", HandleGet);
|
||||||
app.MapPut("/api/profile", HandleUpdate);
|
app.MapPut("/api/profile", HandleUpdate);
|
||||||
app.MapPut("/api/profile/password", HandlePasswordChange);
|
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)
|
private static async System.Threading.Tasks.Task HandleGet(HttpContext ctx, DatabaseService db)
|
||||||
|
|||||||
@@ -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);
|
var session = await db.GetSession(sessionId);
|
||||||
await ctx.Response.WriteAsJsonAsync(session);
|
await ctx.Response.WriteAsJsonAsync(session);
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ public partial class DatabaseService
|
|||||||
new { sessionId, now = DateTime.UtcNow.ToString("o") });
|
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();
|
using var conn = CreateConnection();
|
||||||
var sets = new List<string> { "updated_at = @now" };
|
var sets = new List<string> { "updated_at = @now" };
|
||||||
@@ -120,6 +120,16 @@ public partial class DatabaseService
|
|||||||
p.Add("sessionId", sessionId);
|
p.Add("sessionId", sessionId);
|
||||||
p.Add("now", DateTime.UtcNow.ToString("o"));
|
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("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("description", out var d)) { sets.Add("description = @desc"); p.Add("desc", d.GetString()); }
|
||||||
if (body.TryGetProperty("image", out var img))
|
if (body.TryGetProperty("image", out var img))
|
||||||
|
|||||||
@@ -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>
|
<button type="button" id="sessionTelegramUnlink" class="icon-btn danger-btn" title="Verknüpfung lösen"><i class="fa fa-chain-broken"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<small class="form-hint">Wird automatisch gesetzt wenn jemand /start an den Bot sendet.</small>
|
<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>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
|
|||||||
@@ -211,6 +211,9 @@ function apiShareMessage(id, includeContext) { return _post('/api/messages/' + i
|
|||||||
function apiGetShared(token) { return _get('/api/shared/' + token); }
|
function apiGetShared(token) { return _get('/api/shared/' + token); }
|
||||||
function apiDeleteShare(token) { return fetch('/api/shared/' + token, { method: 'DELETE' }); }
|
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 ──
|
// ── Admin User API ──
|
||||||
|
|
||||||
function apiAdminGetUsers() { return _get('/api/admin/users'); }
|
function apiAdminGetUsers() { return _get('/api/admin/users'); }
|
||||||
|
|||||||
@@ -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 ──
|
// ── Agent Card Helper ──
|
||||||
// Erzeugt ein .card-Element für einen Agent (wiederverwendbar für Dashboard + Picker).
|
// Erzeugt ein .card-Element für einen Agent (wiederverwendbar für Dashboard + Picker).
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ registerPage('session', {
|
|||||||
const addCommandBtn = document.getElementById('addCommandBtn');
|
const addCommandBtn = document.getElementById('addCommandBtn');
|
||||||
const unityMcpUrlEl = document.getElementById('sessionUnityMcpUrl');
|
const unityMcpUrlEl = document.getElementById('sessionUnityMcpUrl');
|
||||||
const unityMcpTokenEl = document.getElementById('sessionUnityMcpToken');
|
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');
|
const more = document.querySelector('.more');
|
||||||
|
|
||||||
// Avatar Editor initialisieren
|
// Avatar Editor initialisieren
|
||||||
@@ -123,6 +126,11 @@ registerPage('session', {
|
|||||||
updateReviewVisibility();
|
updateReviewVisibility();
|
||||||
setupAgentPicker(finalizerAgentSelectCard, finalizerAgentSelect, session && session.finalizerAgentId, 'fa-flag-checkered', 'Kein Finalizer-Agent');
|
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);
|
initMoreButton(more);
|
||||||
|
|
||||||
// Workspace Browse
|
// Workspace Browse
|
||||||
@@ -179,6 +187,16 @@ registerPage('session', {
|
|||||||
unityMcpToken: unityMcpTokenEl.value.trim() || null
|
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) {
|
if (isNew) {
|
||||||
await apiCreateSession(data);
|
await apiCreateSession(data);
|
||||||
} else {
|
} 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); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in new issue
Block a user