From a7a4419626ebcabb68b6011d6d1ce316dbe49374 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 7 Oct 2026 18:31:05 +0200 Subject: [PATCH] =?UTF-8?q?Adminrecht=20geben=20und=20nehmen=20=E2=80=94?= =?UTF-8?q?=20und=20einen,=20dem=20es=20niemand=20nimmt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Konto liess sich anlegen und danach nie wieder ändern: kein Weg zum Administrator, keiner zurück, keiner zum Löschen. Mit Einladungslinks füllte sich der Server also in genau eine Richtung. **Warum es einen Besitzer braucht.** Sobald Administratoren einander das Recht entziehen können, gibt es einen Zustand ohne jeden Administrator — und aus dem führt kein Weg zurück ausser dem an der Datenbank. Ein Konto, das ausgenommen ist, schliesst ihn aus. Es ist das erste, also das, mit dem eingerichtet wurde; die Migration trägt das auf bestehenden Servern nach, und weil die Kennungen zeitlich geordnet vergeben werden, ist der Älteste schlicht der mit der kleinsten. Weitergeben lässt er sich: vom Besitzer, an einen Administrator, mit Rückfrage. Nur an einen Administrator, sonst wäre der Server einen Klick von einem Besitzer entfernt, der die Verwaltung gar nicht sieht. **Drei Türen sind zugemauert**, und jede hat ihren Test: dem Besitzer das Recht nehmen, den Besitzer löschen, sich selbst das eigene Recht nehmen. Das letzte ist die unauffälligste — wer sich im Eifer entmachtet, steht vor einer Verwaltung, die er nicht mehr öffnen kann, und das Zurücknehmen braucht genau die. Gemessen und als Test festgehalten: ein frisch vergebenes Adminrecht gilt **sofort**, ohne neue Anmeldung. Die Rolle wird je Anfrage aus der Datenbank gelesen und nicht aus dem Token — ich hatte das Gegenteil befürchtet. Löschen nimmt mit, was am Konto hing: Geräte, Sehstände, Sehprotokoll, Meldungen, offene Einladungen. Alles hängt mit `Cascade` daran. Zahlen über jemanden zu führen, den es nicht mehr gibt, wäre die schlechtere Hälfte. Co-Authored-By: Claude Opus 5 --- .../src/Flaro.Api/Endpoints/AuthEndpoints.cs | 4 +- server/src/Flaro.Api/Endpoints/Contracts.cs | 14 +- .../Endpoints/ManagementEndpoints.cs | 76 +- .../Flaro.Api/Endpoints/ProfilEndpoints.cs | 2 +- .../Flaro.Api/Endpoints/SystemEndpoints.cs | 4 + server/src/Flaro.Core/Users/User.cs | 18 + .../20261007162057_Besitzer.Designer.cs | 1131 +++++++++++++++++ .../Migrations/20261007162057_Besitzer.cs | 43 + .../Migrations/FlaroContextModelSnapshot.cs | 3 + server/tests/Flaro.Api.Tests/BesitzerTests.cs | 189 +++ web/app/src/api.ts | 24 + web/app/src/views/einstellungen/benutzer.ts | 83 +- 12 files changed, 1576 insertions(+), 15 deletions(-) create mode 100644 server/src/Flaro.Data/Migrations/20261007162057_Besitzer.Designer.cs create mode 100644 server/src/Flaro.Data/Migrations/20261007162057_Besitzer.cs create mode 100644 server/tests/Flaro.Api.Tests/BesitzerTests.cs diff --git a/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs b/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs index 3f2ee69..5c10712 100644 --- a/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs @@ -91,7 +91,7 @@ public static class AuthEndpoints return Results.Ok(new AuthenticatedUser( benutzer.Id, benutzer.UserName, benutzer.IsAdministrator, - benutzer.Language, benutzer.AvatarImage)); + benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner)); }).RequireAuthorization(); MapCodeFlow(app); @@ -200,7 +200,7 @@ public static class AuthEndpoints return new LoginResponse(token, new AuthenticatedUser( benutzer.Id, benutzer.UserName, benutzer.IsAdministrator, - benutzer.Language, benutzer.AvatarImage)); + benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner)); } private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName; diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 1baab45..c1b313c 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -91,7 +91,16 @@ public sealed record AuthenticatedUser( /// auf jeder Seite; es nach der Anmeldung noch einmal holen zu müssen /// hiesse, dass der Buchstabenkreis kurz aufblitzt und dann wechselt. /// - string? AvatarImage = null); + string? AvatarImage = null, + + /// Der Besitzer — ihm nimmt niemand das Adminrecht. + /// + /// + /// Hinten angehängt, wie jedes neue Feld in dieser Datei: in der Mitte + /// eingeschoben bricht es jede Aufrufstelle, die die Werte der Reihe nach + /// übergibt — und davon gibt es hier drei. + /// + bool IsOwner = false); public sealed record LoginResponse(string Token, AuthenticatedUser User); @@ -109,6 +118,9 @@ public sealed record CodeState(string State, LoginResponse? Login); public sealed record CreateUserRequest(string UserName, string Password, bool IsAdministrator); +/// Adminrecht geben oder nehmen. +public sealed record RechtRequest(bool IsAdministrator); + /// Eine Bibliothek, wie die Oberfläche sie zeigt. /// /// diff --git a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs index a7155cb..b39ad4b 100644 --- a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Flaro.Api.Auth; using Flaro.Core.Libraries; using Flaro.Core.Library; @@ -9,6 +10,9 @@ namespace Flaro.Api.Endpoints; public static class ManagementEndpoints { + private static Guid? Kennung(ClaimsPrincipal wer) => + Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null; + public static void MapManagement(this IEndpointRouteBuilder app) { var benutzer = app.MapGroup("/api/v1/users").RequireAuthorization(FlaroPolicies.Administrator); @@ -16,9 +20,79 @@ public static class ManagementEndpoints benutzer.MapGet("/", async (FlaroContext db) => Results.Ok(await db.Users .OrderBy(u => u.UserName) - .Select(u => new AuthenticatedUser(u.Id, u.UserName, u.IsAdministrator)) + .Select(u => new AuthenticatedUser( + u.Id, u.UserName, u.IsAdministrator, u.Language, u.AvatarImage, u.IsOwner)) .ToListAsync())); + /* ---------- Rechte, Besitz, Löschen ---------- */ + + // **Warum es einen Besitzer gibt.** Sobald Administratoren einander + // das Recht entziehen können, gibt es einen Zustand ohne jeden + // Administrator — und aus dem führt kein Weg zurück ausser dem an der + // Datenbank. Ein Konto, das ausgenommen ist, schliesst ihn aus. + benutzer.MapPut("/{id:guid}/administrator", async ( + Guid id, RechtRequest anfrage, ClaimsPrincipal wer, + FlaroContext db, CancellationToken ct) => + { + var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct); + if (ziel is null) return Einwand.Fehlt("Dieses Konto gibt es nicht mehr."); + + if (ziel.IsOwner && !anfrage.IsAdministrator) + return Einwand.Bitte("Dem Besitzer lässt sich das Adminrecht nicht nehmen."); + + // **Nicht an sich selbst.** Wer sich im Eifer das eigene Recht + // nimmt, steht vor einer Verwaltung, die er nicht mehr öffnen + // kann — und das Zurücknehmen braucht genau die. + if (Kennung(wer) == id && !anfrage.IsAdministrator) + return Einwand.Bitte("Das eigene Adminrecht kannst du dir nicht selbst nehmen."); + + ziel.IsAdministrator = anfrage.IsAdministrator; + await db.SaveChangesAsync(ct); + return Results.NoContent(); + }); + + // **Nur der Besitzer gibt den Besitz weiter**, und nur an jemanden, + // der schon Administrator ist: sonst wäre der Server einen Klick von + // einem Besitzer entfernt, der die Verwaltung gar nicht sieht. + benutzer.MapPost("/{id:guid}/owner", async ( + Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) => + { + if (Kennung(wer) is not { } ich) return Results.Unauthorized(); + + var bisher = await db.Users.FirstOrDefaultAsync(u => u.Id == ich, ct); + if (bisher is not { IsOwner: true }) + return Einwand.Bitte("Nur der Besitzer kann den Besitz weitergeben."); + + var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct); + if (ziel is null) return Einwand.Fehlt("Dieses Konto gibt es nicht mehr."); + if (!ziel.IsAdministrator) + return Einwand.Bitte("Der Besitz geht nur an einen Administrator."); + + bisher.IsOwner = false; + ziel.IsOwner = true; + await db.SaveChangesAsync(ct); + return Results.NoContent(); + }); + + benutzer.MapDelete("/{id:guid}", async ( + Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) => + { + var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct); + if (ziel is null) return Results.NoContent(); + + if (ziel.IsOwner) return Einwand.Bitte("Den Besitzer kann man nicht löschen."); + if (Kennung(wer) == id) + return Einwand.Bitte("Das eigene Konto kannst du hier nicht löschen."); + + // Geräte, Sehstände, Sehprotokoll, Meldungen und offene + // Einladungen hängen mit `Cascade` daran — siehe `FlaroContext`. + // Ein Konto zu löschen und seine Spuren zu lassen hiesse, Zahlen + // über jemanden zu führen, den es nicht mehr gibt. + db.Users.Remove(ziel); + await db.SaveChangesAsync(ct); + return Results.NoContent(); + }); + benutzer.MapPost("/", async (CreateUserRequest anfrage, FlaroContext db) => { var name = anfrage.UserName.Trim(); diff --git a/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs b/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs index 6a5336d..ec2fb30 100644 --- a/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs @@ -46,7 +46,7 @@ public static class ProfilEndpoints ? Results.Unauthorized() : Results.Ok(new AuthenticatedUser( benutzer.Id, benutzer.UserName, benutzer.IsAdministrator, - benutzer.Language, benutzer.AvatarImage)); + benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner)); }); gruppe.MapPost("/avatar", async ( diff --git a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs index 1848fdf..ee4ae61 100644 --- a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs @@ -208,6 +208,10 @@ public static class SystemEndpoints UserName = anfrage.UserName.Trim(), PasswordHash = PasswordHasher.Hash(anfrage.Password), IsAdministrator = true, + // **Wer einrichtet, besitzt.** Ihm nimmt niemand das + // Adminrecht — sonst gäbe es einen Zustand ohne jeden + // Administrator, und aus dem führt kein Weg zurück. + IsOwner = true, }; db.Users.Add(administrator); await db.SaveChangesAsync(); diff --git a/server/src/Flaro.Core/Users/User.cs b/server/src/Flaro.Core/Users/User.cs index 020756a..dfa069a 100644 --- a/server/src/Flaro.Core/Users/User.cs +++ b/server/src/Flaro.Core/Users/User.cs @@ -19,6 +19,24 @@ public sealed class User public bool IsAdministrator { get; set; } + /// Dem Besitzer kann niemand das Adminrecht nehmen. + /// + /// + /// + /// Damit sich ein Server nicht selbst aussperren kann. Sobald + /// Administratoren einander das Recht entziehen können, gibt es einen + /// Zustand ohne jeden Administrator — und aus dem führt kein Weg zurück + /// ausser dem an der Datenbank. Ein Konto, das ausgenommen ist, schliesst + /// diesen Zustand aus. + /// + /// + /// Es ist das erste Konto, also das, mit dem der Server + /// eingerichtet wurde. Übertragen lässt es sich — an einen anderen + /// Administrator, und nur vom Besitzer selbst. + /// + /// + public bool IsOwner { get; set; } + /// Die bevorzugte Sprache — null heisst: die des Servers. /// /// diff --git a/server/src/Flaro.Data/Migrations/20261007162057_Besitzer.Designer.cs b/server/src/Flaro.Data/Migrations/20261007162057_Besitzer.Designer.cs new file mode 100644 index 0000000..ac215b1 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261007162057_Besitzer.Designer.cs @@ -0,0 +1,1131 @@ +// +using System; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + [DbContext(typeof(FlaroContext))] + [Migration("20261007162057_Besitzer")] + partial class Besitzer + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("HandleFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("HandleFingerprint") + .IsUnique(); + + b.ToTable("AuthCodes"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.Einladung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("MaxUses") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UsedCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.ToTable("Einladungen"); + }); + + modelBuilder.Entity("Flaro.Core.Libraries.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReviewNewItems") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Character") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("PersonId"); + + b.HasIndex("SeriesId"); + + b.ToTable("Credits"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StillImage") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber") + .IsUnique(); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("GenreId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("GenreId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("GenreLinks"); + }); + + modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("INTEGER"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId", "Language") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("MovieId", "Language") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("SeriesId", "Language") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Translations"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("Container") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("MissingSince") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ProbedAt") + .HasColumnType("INTEGER"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("VersionLabel") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("LibraryId", "Path") + .IsUnique(); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MediaFileId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId", "Index") + .IsUnique(); + + b.ToTable("MediaStreams"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Meldung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Art") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("ErledigtAm") + .HasColumnType("INTEGER"); + + b.Property("ErledigtVon") + .HasColumnType("TEXT"); + + b.Property("GemeldetAm") + .HasColumnType("INTEGER"); + + b.Property("Kommentar") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Stelle") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("ErledigtAm", "GemeldetAm"); + + b.HasIndex("UserId", "GemeldetAm"); + + b.ToTable("Meldungen"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Image") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Sekunden") + .HasColumnType("REAL"); + + b.Property("Tag") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("UserId", "Tag"); + + b.HasIndex("UserId", "Tag", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "Tag", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.ToTable("Sehtage"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Dismissed") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("Favourite") + .HasColumnType("INTEGER"); + + b.Property("InProgress") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Watched") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("UserId", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("UserId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.HasIndex("UserId", "InProgress", "UpdatedAt"); + + b.ToTable("UserItemStates"); + }); + + modelBuilder.Entity("Flaro.Core.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Flaro.Core.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarImage") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IsAdministrator") + .HasColumnType("INTEGER"); + + b.Property("IsOwner") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Auth.Einladung", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Person", null) + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.HasOne("Flaro.Core.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.HasOne("Flaro.Core.Library.MediaFile", null) + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Meldung", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Flaro.Data/Migrations/20261007162057_Besitzer.cs b/server/src/Flaro.Data/Migrations/20261007162057_Besitzer.cs new file mode 100644 index 0000000..69596bb --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261007162057_Besitzer.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Besitzer : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsOwner", + table: "Users", + type: "INTEGER", + nullable: false, + defaultValue: false); + + // **Der Älteste erbt.** Auf einem Server, den es schon gibt, ist + // der Besitzer das Konto, mit dem eingerichtet wurde — und das + // ist das mit der kleinsten Kennung, weil die zeitlich geordnet + // vergeben werden (`Guid.CreateVersion7`). + // + // Ohne diese Zeilen hätte ein bestehender Server gar keinen + // Besitzer, und das Adminrecht liesse sich bis zum letzten + // Administrator abräumen. + migrationBuilder.Sql( + """ + UPDATE Users SET IsOwner = 1 + WHERE Id = (SELECT Id FROM Users ORDER BY Id LIMIT 1); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsOwner", + table: "Users"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index 789f8b7..2822e8f 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -929,6 +929,9 @@ namespace Flaro.Data.Migrations b.Property("IsAdministrator") .HasColumnType("INTEGER"); + b.Property("IsOwner") + .HasColumnType("INTEGER"); + b.Property("Language") .HasMaxLength(16) .HasColumnType("TEXT"); diff --git a/server/tests/Flaro.Api.Tests/BesitzerTests.cs b/server/tests/Flaro.Api.Tests/BesitzerTests.cs new file mode 100644 index 0000000..2809fb1 --- /dev/null +++ b/server/tests/Flaro.Api.Tests/BesitzerTests.cs @@ -0,0 +1,189 @@ +using System.Net; +using System.Net.Http.Json; +using Flaro.Api.Endpoints; + +namespace Flaro.Api.Tests; + +/// Adminrecht geben und nehmen — und der eine, dem es niemand nimmt. +/// +/// +/// Worum es hier wirklich geht: ein Server ohne jeden Administrator. +/// Sobald Administratoren einander das Recht entziehen können, gibt es diesen +/// Zustand — und aus ihm führt kein Weg zurück ausser dem an der Datenbank. +/// Jeder Test hier prüft eine der Türen, durch die man hineingeraten könnte. +/// +public class BesitzerTests +{ + [Test] + public async Task Wer_einrichtet_ist_Besitzer() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + + var ich = await alsAdmin.GetFromJsonAsync("/api/v1/users/me"); + + Assert.Multiple(() => + { + Assert.That(admin.User.IsOwner, Is.True, "Schon die Anmeldung muss es sagen."); + Assert.That(ich!.IsOwner, Is.True); + }); + } + + [Test] + public async Task Aus_einem_Benutzer_wird_ein_Administrator_und_zurueck() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang"); + + var vorher = await gast.GetAsync("/api/v1/users"); + + (await alsAdmin.PutAsJsonAsync($"/api/v1/users/{await KennungVon(alsAdmin, "gast")}/administrator", + new RechtRequest(true))).EnsureSuccessStatusCode(); + + // **Die Rolle wird je Anfrage frisch gelesen**, nicht aus dem Token — + // der Gast braucht sich also nicht neu anzumelden. + var danach = await gast.GetAsync("/api/v1/users"); + + (await alsAdmin.PutAsJsonAsync($"/api/v1/users/{await KennungVon(alsAdmin, "gast")}/administrator", + new RechtRequest(false))).EnsureSuccessStatusCode(); + var wiederZu = await gast.GetAsync("/api/v1/users"); + + Assert.Multiple(() => + { + Assert.That(vorher.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + Assert.That(danach.StatusCode, Is.EqualTo(HttpStatusCode.OK), + "Das Recht muss sofort gelten, ohne neue Anmeldung."); + Assert.That(wiederZu.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + }); + } + + [Test] + public async Task Dem_Besitzer_nimmt_niemand_das_Adminrecht() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + + var zweiter = await server.NeuerBenutzer(alsAdmin, "zweiter", "zweiterpasswortlang"); + var zweiteKennung = await KennungVon(alsAdmin, "zweiter"); + await alsAdmin.PutAsJsonAsync($"/api/v1/users/{zweiteKennung}/administrator", + new RechtRequest(true)); + + // Auch ein zweiter Administrator kommt an den Besitzer nicht heran. + var versuch = await zweiter.PutAsJsonAsync( + $"/api/v1/users/{admin.User.Id}/administrator", new RechtRequest(false)); + var geloescht = await zweiter.DeleteAsync($"/api/v1/users/{admin.User.Id}"); + + Assert.Multiple(() => + { + Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(geloescht.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + }); + Assert.That(await versuch.Content.ReadAsStringAsync(), Does.Contain("Besitzer")); + } + + [Test] + public async Task Das_eigene_Adminrecht_nimmt_man_sich_nicht_selbst() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + + var zweiter = await server.NeuerBenutzer(alsAdmin, "zweiter", "zweiterpasswortlang"); + var zweiteKennung = await KennungVon(alsAdmin, "zweiter"); + await alsAdmin.PutAsJsonAsync($"/api/v1/users/{zweiteKennung}/administrator", + new RechtRequest(true)); + + var selbst = await zweiter.PutAsJsonAsync( + $"/api/v1/users/{zweiteKennung}/administrator", new RechtRequest(false)); + var selbstWeg = await zweiter.DeleteAsync($"/api/v1/users/{zweiteKennung}"); + + Assert.Multiple(() => + { + Assert.That(selbst.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(selbstWeg.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + }); + } + + [Test] + public async Task Der_Besitz_geht_nur_vom_Besitzer_und_nur_an_einen_Administrator() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang"); + var gastKennung = await KennungVon(alsAdmin, "gast"); + + // Noch kein Administrator — also noch kein Besitzer. + var zuFrueh = await alsAdmin.PostAsync($"/api/v1/users/{gastKennung}/owner", null); + + await alsAdmin.PutAsJsonAsync($"/api/v1/users/{gastKennung}/administrator", + new RechtRequest(true)); + + // Der Beschenkte kann sich den Besitz nicht selbst nehmen. + var selbstGenommen = await gast.PostAsync($"/api/v1/users/{gastKennung}/owner", null); + + var uebergeben = await alsAdmin.PostAsync($"/api/v1/users/{gastKennung}/owner", null); + var alle = await alsAdmin.GetFromJsonAsync>("/api/v1/users"); + + Assert.Multiple(() => + { + Assert.That(zuFrueh.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(selbstGenommen.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(uebergeben.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + Assert.That(alle!.Count(u => u.IsOwner), Is.EqualTo(1), "Genau einer, immer."); + Assert.That(alle!.Single(u => u.IsOwner).UserName, Is.EqualTo("gast")); + }); + } + + /// Ein gelöschtes Konto nimmt mit, was an ihm hing. + [Test] + public async Task Ein_geloeschtes_Konto_laesst_nichts_zurueck() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang"); + var gastKennung = await KennungVon(alsAdmin, "gast"); + + // Etwas, das an ihm hängt. + await gast.PostAsJsonAsync($"/api/v1/items/{Guid.CreateVersion7()}/reports", + new MeldungRequest("bild", "Egal.")); + + (await alsAdmin.DeleteAsync($"/api/v1/users/{gastKennung}")).EnsureSuccessStatusCode(); + + var alle = await alsAdmin.GetFromJsonAsync>("/api/v1/users"); + // Das alte Token gilt nicht mehr: die Geräte hingen am Konto. + var mitAltemToken = await gast.GetAsync("/api/v1/users/me"); + + Assert.Multiple(() => + { + Assert.That(alle!.Select(u => u.UserName), Does.Not.Contain("gast")); + Assert.That(mitAltemToken.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + }); + } + + [Test] + public async Task Rechte_vergeben_darf_nur_die_Verwaltung() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang"); + var gastKennung = await KennungVon(alsAdmin, "gast"); + + var versuch = await gast.PutAsJsonAsync( + $"/api/v1/users/{gastKennung}/administrator", new RechtRequest(true)); + + Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + private static async Task KennungVon(HttpClient alsAdmin, string name) + { + var alle = await alsAdmin.GetFromJsonAsync>("/api/v1/users"); + return alle!.Single(u => u.UserName == name).Id; + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 1a7d4e5..7120125 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -5,6 +5,8 @@ export type Rolle = { id: string; userName: string; isAdministrator: boolean; + /** Der Besitzer — ihm nimmt niemand das Adminrecht. */ + isOwner?: boolean; /** Die bevorzugte Sprache — `null` heisst: die des Servers. */ language?: string | null; /** Das eigene Bild, als Schlüssel in die Bildablage. `null`: Buchstabenkreis. */ @@ -174,6 +176,8 @@ export type Systemauskunft = { build: string; serverName: string | null; needsSetup: boolean; attributions: Hinweis[]; + /** Eine der vier Stufen des Aufmachers: niedrig, mittel, hoch, sehrhoch. */ + heroHeight?: string; }; /* ---------- Sitzung ---------- */ @@ -403,6 +407,12 @@ export const bildAdresse = (schluessel: string | null): string | null => export const systemauskunft = () => anfrage('/system/info'); +/** Setzt die Hoehe des Aufmachers — serverweit, nur Admin. */ +export const aufmacherhoeheSetzen = (stufe: string) => + anfrage('/system/hero-height', { + method: 'PUT', body: JSON.stringify({ height: stufe }), + }); + export const einrichten = (benutzer: string, passwort: string) => anfrage('/system/setup', { method: 'POST', body: JSON.stringify({ userName: benutzer, password: passwort }), @@ -1081,6 +1091,20 @@ export const benutzerAnlegen = (name: string, passwort: string, admin: boolean) body: JSON.stringify({ userName: name, password: passwort, isAdministrator: admin }), }); +/** Gibt oder nimmt das Adminrecht. Dem Besitzer nimmt es niemand. */ +export const adminrechtSetzen = (id: string, admin: boolean) => + anfrage(`/users/${id}/administrator`, { + method: 'PUT', body: JSON.stringify({ isAdministrator: admin }), + }); + +/** Gibt den Besitz weiter — nur der Besitzer, nur an einen Administrator. */ +export const besitzUebergeben = (id: string) => + anfrage(`/users/${id}/owner`, { method: 'POST' }); + +/** Loescht ein Konto samt allem, was daran haengt. */ +export const benutzerLoeschen = (id: string) => + anfrage(`/users/${id}`, { method: 'DELETE' }); + /* ---------- Einladungen ---------- */ export type Einladung = { diff --git a/web/app/src/views/einstellungen/benutzer.ts b/web/app/src/views/einstellungen/benutzer.ts index 6c2a220..da036ed 100644 --- a/web/app/src/views/einstellungen/benutzer.ts +++ b/web/app/src/views/einstellungen/benutzer.ts @@ -1,9 +1,10 @@ import { - benutzer, benutzerAnlegen, - einladungAnlegen, einladungen, einladungZurueckziehen, type Einladung, + benutzer, benutzerAnlegen, adminrechtSetzen, besitzUebergeben, benutzerLoeschen, + einladungAnlegen, einladungen, einladungZurueckziehen, sitzung, + type Einladung, type Rolle, } from '../../api'; import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../../util'; -import { dialog } from '../../dialog'; +import { dialog, nachfragen } from '../../dialog'; import { abschnitt } from './teile'; @@ -96,6 +97,74 @@ export function benutzerbereich(): HTMLElement { void laden(); + /** Ein Konto mit dem, was man daran tun darf. + * + * **Der Besitzer ist ausgenommen.** Sobald Administratoren einander das + * Recht entziehen können, gibt es einen Zustand ohne jeden Administrator — + * und aus dem führt kein Weg zurück ausser dem an der Datenbank. Darum + * trägt ein Konto diese Marke, und an ihm sind beide Knöpfe aus. */ + function kontozeile(b: Rolle): HTMLElement { + const d = document.createElement('div'); + d.className = 'einstellung'; + + const ich = b.id === sitzung.wer?.id; + const rolle = b.isOwner ? 'Besitzer' + : b.isAdministrator ? 'Administrator' : 'Benutzer'; + + const text = document.createElement('div'); + text.className = 'zeile'; + text.style.flex = '1'; + text.innerHTML = `${esc(b.userName)}${ich ? ' · das bist du' : ''}` + + `${esc(rolle)}`; + + const reihe = document.createElement('div'); + reihe.className = 'knopfreihe'; + reihe.appendChild(text); + + // **Dem Besitzer nimmt niemand etwas, und sich selbst auch nicht.** Wer + // sich im Eifer das eigene Recht nimmt, steht vor einer Verwaltung, die + // er nicht mehr öffnen kann — und das Zurücknehmen braucht genau die. + if (!b.isOwner && !(ich && b.isAdministrator)) { + const recht = knopf(b.isAdministrator ? 'Adminrecht nehmen' : 'Zum Administrator', 'btn'); + recht.onclick = async () => { + recht.disabled = true; + try { await adminrechtSetzen(b.id, !b.isAdministrator); await laden(); } + catch (fehler) { recht.disabled = false; d.appendChild(fehlerzeile(meldung(fehler))); } + }; + reihe.appendChild(recht); + } + + // Weitergeben darf nur der Besitzer, und nur an einen Administrator. + if (sitzung.wer?.isOwner && !b.isOwner && b.isAdministrator) { + const uebergeben = knopf('Besitz übergeben', 'btn'); + uebergeben.onclick = () => nachfragen({ + titel: 'Besitz übergeben', + text: `„${b.userName}“ wird Besitzer dieses Servers. Dir nimmt dann ` + + 'niemand mehr das Adminrecht ab — aber er kann es dir nehmen, ' + + 'und zurückholen kannst du den Besitz nicht.', + bestaetigen: 'Übergeben', + tun: async () => { await besitzUebergeben(b.id); location.reload(); }, + }); + reihe.appendChild(uebergeben); + } + + if (!b.isOwner && !ich) { + const weg = knopf('Löschen', 'btn btn-gefahr'); + weg.onclick = () => nachfragen({ + titel: `„${b.userName}“ löschen`, + text: 'Das Konto und alles, was daran hängt: Geräte, Sehstände, das ' + + 'Sehprotokoll, Meldungen und offene Einladungen. Das lässt sich ' + + 'nicht zurückholen.', + bestaetigen: 'Löschen', + tun: async () => { await benutzerLoeschen(b.id); await laden(); }, + }); + reihe.appendChild(weg); + } + + d.appendChild(reihe); + return d; + } + /** Der fertige Link, genau einmal. * * **Die Adresse baut der Client.** Der Server weiss nicht, unter welchem @@ -173,13 +242,7 @@ export function benutzerbereich(): HTMLElement { try { const alle = await benutzer(); liste.innerHTML = ''; - alle.forEach(b => { - const d = document.createElement('div'); - d.className = 'einstellung'; - d.innerHTML = `
${esc(b.userName)}` - + `${b.isAdministrator ? 'Administrator' : 'Benutzer'}
`; - liste.appendChild(d); - }); + alle.forEach(b => liste.appendChild(kontozeile(b))); } catch (fehler) { liste.innerHTML = ''; liste.appendChild(fehlerzeile(meldung(fehler)));