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>
118 lines
4.2 KiB
C#
118 lines
4.2 KiB
C#
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 ProfileEndpoint
|
||
{
|
||
public static void MapProfileEndpoints(this WebApplication app)
|
||
{
|
||
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)
|
||
{
|
||
var userId = ctx.GetUserId();
|
||
var user = await db.GetUser(userId);
|
||
if (user == null)
|
||
{
|
||
ctx.Response.StatusCode = 404;
|
||
await ctx.Response.WriteAsJsonAsync(new { error = "User not found" });
|
||
return;
|
||
}
|
||
|
||
await ctx.Response.WriteAsJsonAsync(new
|
||
{
|
||
userId = user.UserId,
|
||
username = user.Username,
|
||
displayName = user.DisplayName,
|
||
persona = user.Persona,
|
||
defaultPath = user.DefaultPath,
|
||
isAdmin = user.IsAdmin
|
||
});
|
||
}
|
||
|
||
private static async System.Threading.Tasks.Task HandleUpdate(HttpContext ctx, DatabaseService db)
|
||
{
|
||
var userId = ctx.GetUserId();
|
||
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
|
||
|
||
var displayName = body.TryGetProperty("displayName", out var dn) ? dn.GetString() : null;
|
||
var persona = body.TryGetProperty("persona", out var p) ? p.GetString() : null;
|
||
|
||
await db.UpdateUserProfile(userId, displayName, persona);
|
||
|
||
var user = await db.GetUser(userId);
|
||
await ctx.Response.WriteAsJsonAsync(new
|
||
{
|
||
userId = user.UserId,
|
||
username = user.Username,
|
||
displayName = user.DisplayName,
|
||
persona = user.Persona,
|
||
defaultPath = user.DefaultPath,
|
||
isAdmin = user.IsAdmin
|
||
});
|
||
}
|
||
|
||
private static async System.Threading.Tasks.Task HandlePasswordChange(HttpContext ctx, DatabaseService db, AuthService auth)
|
||
{
|
||
var userId = ctx.GetUserId();
|
||
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
|
||
|
||
var currentPassword = body.TryGetProperty("currentPassword", out var cp) ? cp.GetString() : null;
|
||
var newPassword = body.TryGetProperty("newPassword", out var np) ? np.GetString() : null;
|
||
|
||
if (string.IsNullOrEmpty(currentPassword) || string.IsNullOrEmpty(newPassword))
|
||
{
|
||
ctx.Response.StatusCode = 400;
|
||
await ctx.Response.WriteAsJsonAsync(new { error = "Aktuelles und neues Passwort erforderlich" });
|
||
return;
|
||
}
|
||
|
||
if (newPassword.Length < 4)
|
||
{
|
||
ctx.Response.StatusCode = 400;
|
||
await ctx.Response.WriteAsJsonAsync(new { error = "Passwort muss mindestens 4 Zeichen lang sein" });
|
||
return;
|
||
}
|
||
|
||
var user = await db.GetUser(userId);
|
||
if (user == null || !auth.VerifyPassword(currentPassword, user.PasswordHash))
|
||
{
|
||
ctx.Response.StatusCode = 403;
|
||
await ctx.Response.WriteAsJsonAsync(new { error = "Aktuelles Passwort ist falsch" });
|
||
return;
|
||
}
|
||
|
||
var newHash = auth.HashPassword(newPassword);
|
||
await db.UpdateUserPassword(userId, newHash);
|
||
|
||
await ctx.Response.WriteAsJsonAsync(new { ok = true });
|
||
}
|
||
}
|