feat: Login-as-User (Impersonate) + Default-Path pro User
Admin:
- POST /api/admin/users/{userId}/impersonate – erstellt Token für Zieluser
- Login-as-Button (fa-sign-in) in User-Liste (nicht für sich selbst)
- confirm-Dialog, danach location.reload() mit neuem User-Kontext
- Default-Path Feld + Browse-Button im Admin-Formular
Default-Path:
- Neues Feld default_path in users-Tabelle (TryAddColumn Migration)
- UserRecord.DefaultPath Property
- In allen Endpoints: Admin CRUD, Auth /me, Profile Get/Update
- Profil-Seite: Default-Path Feld + Browse-Button
- Session-Seite: Workspace-Browse nutzt defaultPath als Fallback
This commit is contained in:
1 parent
df68843e82
commit
d1355dcb0d
10 files changed
+144
-17
No files matched your search
@@ -17,6 +17,7 @@ public static class AdminEndpoint
|
||||
app.MapPut("/api/admin/users/{userId}", HandleUpdateUser);
|
||||
app.MapPut("/api/admin/users/{userId}/password", HandleResetPassword);
|
||||
app.MapDelete("/api/admin/users/{userId}", HandleDeleteUser);
|
||||
app.MapPost("/api/admin/users/{userId}/impersonate", HandleImpersonate);
|
||||
}
|
||||
|
||||
private static async System.Threading.Tasks.Task HandleListUsers(HttpContext ctx, DatabaseService db)
|
||||
@@ -35,6 +36,7 @@ public static class AdminEndpoint
|
||||
username = u.Username,
|
||||
displayName = u.DisplayName,
|
||||
isAdmin = u.IsAdmin,
|
||||
defaultPath = u.DefaultPath,
|
||||
createdAt = u.CreatedAt,
|
||||
updatedAt = u.UpdatedAt
|
||||
});
|
||||
@@ -56,6 +58,7 @@ public static class AdminEndpoint
|
||||
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();
|
||||
var defaultPath = body.TryGetProperty("defaultPath", out var dp) ? dp.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
@@ -80,7 +83,7 @@ public static class AdminEndpoint
|
||||
}
|
||||
|
||||
var hash = auth.HashPassword(password);
|
||||
var user = await db.CreateUser(username, hash, displayName, isAdmin);
|
||||
var user = await db.CreateUser(username, hash, displayName, isAdmin, defaultPath);
|
||||
|
||||
ctx.Response.StatusCode = 201;
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
@@ -89,6 +92,7 @@ public static class AdminEndpoint
|
||||
username = user.Username,
|
||||
displayName = user.DisplayName,
|
||||
isAdmin = user.IsAdmin,
|
||||
defaultPath = user.DefaultPath,
|
||||
createdAt = user.CreatedAt
|
||||
});
|
||||
}
|
||||
@@ -114,6 +118,7 @@ public static class AdminEndpoint
|
||||
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;
|
||||
var defaultPath = body.TryGetProperty("defaultPath", out var dp) ? dp.GetString() : user.DefaultPath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
@@ -131,7 +136,7 @@ public static class AdminEndpoint
|
||||
return;
|
||||
}
|
||||
|
||||
await db.UpdateUserAdmin(userId, username, displayName, isAdmin);
|
||||
await db.UpdateUserAdmin(userId, username, displayName, isAdmin, defaultPath);
|
||||
var updated = await db.GetUser(userId);
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
@@ -140,6 +145,7 @@ public static class AdminEndpoint
|
||||
username = updated.Username,
|
||||
displayName = updated.DisplayName,
|
||||
isAdmin = updated.IsAdmin,
|
||||
defaultPath = updated.DefaultPath,
|
||||
createdAt = updated.CreatedAt,
|
||||
updatedAt = updated.UpdatedAt
|
||||
});
|
||||
@@ -207,4 +213,41 @@ public static class AdminEndpoint
|
||||
await db.DeleteUser(userId);
|
||||
await ctx.Response.WriteAsJsonAsync(new { ok = true });
|
||||
}
|
||||
|
||||
private static async System.Threading.Tasks.Task HandleImpersonate(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;
|
||||
}
|
||||
|
||||
// Token für den Zieluser erstellen und als Cookie setzen
|
||||
var token = auth.CreateToken(user.UserId);
|
||||
|
||||
ctx.Response.Cookies.Append("gott_token", token, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/",
|
||||
MaxAge = System.TimeSpan.FromDays(365)
|
||||
});
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
userId = user.UserId,
|
||||
username = user.Username,
|
||||
displayName = user.DisplayName,
|
||||
isAdmin = user.IsAdmin
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,8 @@ public static class AuthEndpoint
|
||||
userId = user.UserId,
|
||||
username = user.Username,
|
||||
displayName = user.DisplayName,
|
||||
isAdmin = user.IsAdmin
|
||||
isAdmin = user.IsAdmin,
|
||||
defaultPath = user.DefaultPath
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ public static class ProfileEndpoint
|
||||
username = user.Username,
|
||||
displayName = user.DisplayName,
|
||||
persona = user.Persona,
|
||||
defaultPath = user.DefaultPath,
|
||||
isAdmin = user.IsAdmin
|
||||
});
|
||||
}
|
||||
@@ -44,8 +45,9 @@ public static class ProfileEndpoint
|
||||
|
||||
var displayName = body.TryGetProperty("displayName", out var dn) ? dn.GetString() : null;
|
||||
var persona = body.TryGetProperty("persona", out var p) ? p.GetString() : null;
|
||||
var defaultPath = body.TryGetProperty("defaultPath", out var dp) ? dp.GetString() : null;
|
||||
|
||||
await db.UpdateUserProfile(userId, displayName, persona);
|
||||
await db.UpdateUserProfile(userId, displayName, persona, defaultPath);
|
||||
|
||||
var user = await db.GetUser(userId);
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
@@ -54,6 +56,7 @@ public static class ProfileEndpoint
|
||||
username = user.Username,
|
||||
displayName = user.DisplayName,
|
||||
persona = user.Persona,
|
||||
defaultPath = user.DefaultPath,
|
||||
isAdmin = user.IsAdmin
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public class UserRecord
|
||||
public bool IsAdmin { get; set; }
|
||||
public string Persona { get; set; }
|
||||
public string Settings { get; set; }
|
||||
public string DefaultPath { get; set; }
|
||||
|
||||
public string GetEffectivePersona()
|
||||
{
|
||||
|
||||
+11
-10
@@ -166,6 +166,7 @@ public class DatabaseService
|
||||
TryAddColumn(conn, "users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
TryAddColumn(conn, "users", "persona", "TEXT");
|
||||
TryAddColumn(conn, "users", "settings", "TEXT");
|
||||
TryAddColumn(conn, "users", "default_path", "TEXT");
|
||||
TryAddColumn(conn, "cronjobs", "notify_session_id", "TEXT");
|
||||
|
||||
conn.Execute(@"
|
||||
@@ -888,12 +889,12 @@ public class DatabaseService
|
||||
new { userId, passwordHash, now = DateTime.UtcNow.ToString("o") });
|
||||
}
|
||||
|
||||
public async Task UpdateUserProfile(string userId, string displayName, string persona)
|
||||
public async Task UpdateUserProfile(string userId, string displayName, string persona, string defaultPath = null)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE users SET display_name = @displayName, persona = @persona, updated_at = @now WHERE user_id = @userId",
|
||||
new { userId, displayName, persona, now = DateTime.UtcNow.ToString("o") });
|
||||
"UPDATE users SET display_name = @displayName, persona = @persona, default_path = @defaultPath, updated_at = @now WHERE user_id = @userId",
|
||||
new { userId, displayName, persona, defaultPath, now = DateTime.UtcNow.ToString("o") });
|
||||
}
|
||||
|
||||
public async Task<string> GetUserSettings(string userId)
|
||||
@@ -912,24 +913,24 @@ public class DatabaseService
|
||||
new { userId, settings, now = DateTime.UtcNow.ToString("o") });
|
||||
}
|
||||
|
||||
public async Task<UserRecord> CreateUser(string username, string passwordHash, string displayName, bool isAdmin)
|
||||
public async Task<UserRecord> CreateUser(string username, string passwordHash, string displayName, bool isAdmin, string defaultPath = null)
|
||||
{
|
||||
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 });
|
||||
INSERT INTO users (user_id, username, password_hash, display_name, is_admin, default_path, created_at, updated_at)
|
||||
VALUES (@userId, @username, @passwordHash, @displayName, @isAdmin, @defaultPath, @now, @now)",
|
||||
new { userId, username, passwordHash, displayName, isAdmin, defaultPath, now });
|
||||
return await GetUser(userId);
|
||||
}
|
||||
|
||||
public async Task UpdateUserAdmin(string userId, string username, string displayName, bool isAdmin)
|
||||
public async Task UpdateUserAdmin(string userId, string username, string displayName, bool isAdmin, string defaultPath)
|
||||
{
|
||||
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") });
|
||||
"UPDATE users SET username = @username, display_name = @displayName, is_admin = @isAdmin, default_path = @defaultPath, updated_at = @now WHERE user_id = @userId",
|
||||
new { userId, username, displayName, isAdmin, defaultPath, now = DateTime.UtcNow.ToString("o") });
|
||||
}
|
||||
|
||||
public async Task DeleteUser(string userId)
|
||||
|
||||
@@ -397,6 +397,12 @@
|
||||
<input type="text" id="profileUsername" disabled>
|
||||
<label>Anzeigename</label>
|
||||
<input type="text" id="profileDisplayName" placeholder="Anzeigename">
|
||||
<label>Standard-Pfad</label>
|
||||
<span class="setting-hint">Wird als Basis-Pfad beim Auswählen eines Workspace in Sessions verwendet.</span>
|
||||
<div class="input-with-btn">
|
||||
<input type="text" id="profileDefaultPath" placeholder="z.B. /home/user/projects" autocomplete="off">
|
||||
<button type="button" id="profileDefaultPathBrowse" class="icon-btn"><i class="fa fa-folder"></i></button>
|
||||
</div>
|
||||
<label>Persona</label>
|
||||
<span class="setting-hint">Wird im System-Prompt verwendet, wenn der Agent "Kennt sein Gegenüber" aktiviert hat.</span>
|
||||
<textarea id="profilePersona" rows="8" placeholder="Beschreibe dich selbst – Name, Rolle, Präferenzen, Kommunikationsstil..."></textarea>
|
||||
@@ -448,6 +454,11 @@
|
||||
<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">
|
||||
<label>Standard-Pfad</label>
|
||||
<div class="input-with-btn">
|
||||
<input type="text" id="adminDefaultPath" placeholder="z.B. /home/user/projects" autocomplete="off">
|
||||
<button type="button" id="adminDefaultPathBrowse" class="icon-btn"><i class="fa fa-folder"></i></button>
|
||||
</div>
|
||||
<div class="admin-checkbox-row">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="adminIsAdmin">
|
||||
|
||||
@@ -463,6 +463,15 @@ async function apiAdminDeleteUser(userId) {
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiAdminImpersonate(userId) {
|
||||
const resp = await fetch('/api/admin/users/' + userId + '/impersonate', { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.error || 'Fehler beim Anmelden');
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// ── Backup API ──
|
||||
|
||||
async function apiBackupDb() {
|
||||
|
||||
@@ -18,6 +18,8 @@ registerPage('admin', {
|
||||
var displayNameInput = document.getElementById('adminDisplayName');
|
||||
var passwordInput = document.getElementById('adminPassword');
|
||||
var passwordLabel = document.getElementById('adminPasswordLabel');
|
||||
var defaultPathInput = document.getElementById('adminDefaultPath');
|
||||
var defaultPathBrowse = document.getElementById('adminDefaultPathBrowse');
|
||||
var isAdminCheckbox = document.getElementById('adminIsAdmin');
|
||||
var saveBtn = document.getElementById('adminFormSaveBtn');
|
||||
var formMessage = document.getElementById('adminFormMessage');
|
||||
@@ -59,7 +61,8 @@ registerPage('admin', {
|
||||
metaSpan.className = 'admin-user-meta';
|
||||
var metaParts = ['@' + user.username];
|
||||
if (user.isAdmin) metaParts.push('Administrator');
|
||||
metaSpan.textContent = metaParts.join(' ▪ ');
|
||||
if (user.defaultPath) metaParts.push(user.defaultPath);
|
||||
metaSpan.textContent = metaParts.join(' · ');
|
||||
|
||||
info.appendChild(nameSpan);
|
||||
info.appendChild(metaSpan);
|
||||
@@ -76,8 +79,17 @@ registerPage('admin', {
|
||||
});
|
||||
actions.appendChild(editBtn);
|
||||
|
||||
// Sich selbst kann man nicht löschen
|
||||
// Login as User (nicht für sich selbst)
|
||||
if (user.userId !== currentUser.userId) {
|
||||
var loginAsBtn = document.createElement('button');
|
||||
loginAsBtn.className = 'icon-btn';
|
||||
loginAsBtn.title = 'Als ' + (user.displayName || user.username) + ' anmelden';
|
||||
loginAsBtn.innerHTML = '<i class="fa fa-sign-in"></i>';
|
||||
loginAsBtn.addEventListener('click', function() {
|
||||
impersonateUser(user);
|
||||
});
|
||||
actions.appendChild(loginAsBtn);
|
||||
|
||||
var deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'icon-btn';
|
||||
deleteBtn.title = 'Löschen';
|
||||
@@ -113,6 +125,7 @@ registerPage('admin', {
|
||||
passwordLabel.textContent = 'Neues Passwort (leer = unverändert)';
|
||||
passwordInput.placeholder = 'Neues Passwort (optional)';
|
||||
passwordInput.required = false;
|
||||
defaultPathInput.value = user.defaultPath || '';
|
||||
isAdminCheckbox.checked = user.isAdmin;
|
||||
saveBtn.textContent = 'Speichern';
|
||||
formMessage.hidden = true;
|
||||
@@ -133,8 +146,34 @@ registerPage('admin', {
|
||||
}
|
||||
}
|
||||
|
||||
async function impersonateUser(user) {
|
||||
if (!confirm('Als "' + (user.displayName || user.username) + '" anmelden?\n\nDu wirst abgemeldet und als dieser Benutzer eingeloggt.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiAdminImpersonate(user.userId);
|
||||
// Seite neu laden, damit der neue User-Kontext geladen wird
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
showPageMessage(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event-Listener ──
|
||||
|
||||
// Default Path Browse
|
||||
if (defaultPathBrowse) {
|
||||
defaultPathBrowse.addEventListener('click', function() {
|
||||
openFolderBrowser({
|
||||
startPath: defaultPathInput.value.trim() || '/',
|
||||
onSelect: function(path) {
|
||||
defaultPathInput.value = path;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Neuer Benutzer
|
||||
newUserBtn.addEventListener('click', function() {
|
||||
editingUserId = null;
|
||||
@@ -146,6 +185,7 @@ registerPage('admin', {
|
||||
passwordLabel.textContent = 'Passwort';
|
||||
passwordInput.placeholder = 'Passwort';
|
||||
passwordInput.required = true;
|
||||
defaultPathInput.value = '';
|
||||
isAdminCheckbox.checked = false;
|
||||
saveBtn.textContent = 'Erstellen';
|
||||
formMessage.hidden = true;
|
||||
@@ -164,6 +204,7 @@ registerPage('admin', {
|
||||
var data = {
|
||||
username: usernameInput.value.trim(),
|
||||
displayName: displayNameInput.value.trim(),
|
||||
defaultPath: defaultPathInput.value.trim() || null,
|
||||
isAdmin: isAdminCheckbox.checked
|
||||
};
|
||||
await apiAdminUpdateUser(editingUserId, data);
|
||||
@@ -189,6 +230,7 @@ registerPage('admin', {
|
||||
username: username,
|
||||
password: password,
|
||||
displayName: displayNameInput.value.trim(),
|
||||
defaultPath: defaultPathInput.value.trim() || null,
|
||||
isAdmin: isAdminCheckbox.checked
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ registerPage('profile', {
|
||||
|
||||
var usernameInput = document.getElementById('profileUsername');
|
||||
var displayNameInput = document.getElementById('profileDisplayName');
|
||||
var defaultPathInput = document.getElementById('profileDefaultPath');
|
||||
var defaultPathBrowse = document.getElementById('profileDefaultPathBrowse');
|
||||
var personaInput = document.getElementById('profilePersona');
|
||||
var saveBtn = document.getElementById('profileSaveBtn');
|
||||
var messageDiv = document.getElementById('profileMessage');
|
||||
@@ -39,6 +41,19 @@ registerPage('profile', {
|
||||
// Felder befüllen
|
||||
usernameInput.value = profile.username || '';
|
||||
displayNameInput.value = profile.displayName || '';
|
||||
defaultPathInput.value = profile.defaultPath || '';
|
||||
|
||||
// Default Path Browse
|
||||
if (defaultPathBrowse) {
|
||||
defaultPathBrowse.addEventListener('click', function() {
|
||||
openFolderBrowser({
|
||||
startPath: defaultPathInput.value.trim() || '/',
|
||||
onSelect: function(path) {
|
||||
defaultPathInput.value = path;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Profil speichern
|
||||
saveBtn.addEventListener('click', async function() {
|
||||
@@ -49,6 +64,7 @@ registerPage('profile', {
|
||||
try {
|
||||
var updated = await apiUpdateProfile({
|
||||
displayName: displayNameInput.value.trim(),
|
||||
defaultPath: defaultPathInput.value.trim() || null,
|
||||
persona: _profileMde.value().trim()
|
||||
});
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ registerPage('session', {
|
||||
if (workspaceBrowse) {
|
||||
workspaceBrowse.addEventListener('click', () => {
|
||||
openFolderBrowser({
|
||||
startPath: workspaceEl.value.trim() || '/',
|
||||
startPath: workspaceEl.value.trim() || (currentUser && currentUser.defaultPath) || '/',
|
||||
onSelect(path) {
|
||||
workspaceEl.value = path;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user