diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 7baae0b..1baab45 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -803,3 +803,39 @@ public sealed record Meldungseintrag( string ReportedBy, /// null heisst: offen. DateTimeOffset? ResolvedAt); + +/* ---------- Einladungen ---------- */ + +/// Was beim Erstellen eines Einladungslinks gewählt wird. +public sealed record EinladungRequest( + /// Wie viele Tage er gilt. + int Days = 7, + /// Wie oft er sich einlösen lässt. null heisst: unbegrenzt. + int? MaxUses = 1); + +/// Der frisch erstellte Link — der Token steht hier zum einzigen Mal. +/// +/// +/// Die Adresse baut der Client. Der Server weiss nicht, unter welchem +/// Namen er von aussen erreichbar ist; hinter einem Reverse Proxy rät er +/// falsch, und ein falscher Einladungslink ist schlimmer als keiner. +/// +public sealed record EinladungErstellt(Guid Id, string Token, DateTimeOffset ExpiresAt, int? MaxUses); + +/// Eine Einladung in der Verwaltungsliste — ohne Token. +public sealed record Einladungseintrag( + Guid Id, + string CreatedBy, + DateTimeOffset CreatedAt, + DateTimeOffset ExpiresAt, + int? MaxUses, + int UsedCount, + DateTimeOffset? RevokedAt, + /// Ob sich damit jetzt noch ein Konto anlegen lässt. + bool Valid); + +/// Was die Einladungsseite zeigt, bevor jemand etwas eintippt. +public sealed record Einladungsschau(string ServerName, string InvitedBy, DateTimeOffset ExpiresAt); + +/// Namen und Passwort, die sich der Eingeladene selbst gibt. +public sealed record EinloesenRequest(string UserName, string Password); diff --git a/server/src/Flaro.Api/Endpoints/EinladungEndpoints.cs b/server/src/Flaro.Api/Endpoints/EinladungEndpoints.cs new file mode 100644 index 0000000..65aa1d6 --- /dev/null +++ b/server/src/Flaro.Api/Endpoints/EinladungEndpoints.cs @@ -0,0 +1,195 @@ +using System.Security.Claims; +using Flaro.Core.Auth; +using Flaro.Core.Users; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Api.Endpoints; + +/// Jemanden über einen Link einladen, statt ein Konto für ihn zu erfinden. +/// +/// +/// +/// Warum. Ein Konto von Hand anzulegen heisst, sich ein Passwort für +/// einen anderen auszudenken und es ihm durchzusagen — über denselben Kanal, +/// über den er den Link bekommen hätte. Wer eingeladen wird, soll sich Namen +/// und Passwort selbst geben. +/// +/// +/// Zwei Welten in einer Datei. Erstellen, auflisten und zurückziehen +/// ist Verwaltung. Ansehen und Einlösen muss jemand können, der noch gar kein +/// Konto hat — also anonym, und damit gedrosselt: es gibt keine globale +/// Drossel in diesem Server, ein anonymer Endpunkt ohne eigene wäre +/// ungebremst. +/// +/// +public static class EinladungEndpoints +{ + private const int HoechstensTage = 365; + + public static void MapEinladungen(this IEndpointRouteBuilder app) + { + var verwalten = app.MapGroup("/api/v1/invites") + .RequireAuthorization(FlaroPolicies.Administrator); + + verwalten.MapPost("", async ( + EinladungRequest anfrage, ClaimsPrincipal wer, + FlaroContext db, TimeProvider uhr, CancellationToken ct) => + { + if (Kennung(wer) is not { } ich) return Results.Unauthorized(); + + var tage = Math.Clamp(anfrage.Days, 1, HoechstensTage); + if (anfrage.MaxUses is { } anzahl && anzahl < 1) + return Einwand.Bitte("Ein Link, der nullmal gilt, ist keiner."); + + // **Das Geheimnis verlässt den Server genau hier.** Gespeichert + // wird nur sein Fingerabdruck; aus der Datenbank lässt sich kein + // Link mehr bauen, und eine verlorene Einladung wird deshalb nicht + // wiedergefunden, sondern neu gemacht. + var token = Secrets.Create(); + var jetzt = uhr.GetUtcNow(); + var einladung = new Einladung + { + TokenFingerprint = Secrets.Fingerprint(token), + CreatedByUserId = ich, + CreatedAt = jetzt, + ExpiresAt = jetzt.AddDays(tage), + MaxUses = anfrage.MaxUses, + }; + + db.Einladungen.Add(einladung); + await db.SaveChangesAsync(ct); + + return Results.Ok(new EinladungErstellt( + einladung.Id, token, einladung.ExpiresAt, einladung.MaxUses)); + }); + + verwalten.MapGet("", async (FlaroContext db, TimeProvider uhr, CancellationToken ct) => + { + var jetzt = uhr.GetUtcNow(); + var roh = await db.Einladungen.AsNoTracking() + .OrderByDescending(e => e.CreatedAt) + .Join(db.Users.AsNoTracking(), e => e.CreatedByUserId, u => u.Id, + (e, u) => new { E = e, u.UserName }) + .ToListAsync(ct); + + return Results.Ok(roh.Select(z => new Einladungseintrag( + z.E.Id, z.UserName, z.E.CreatedAt, z.E.ExpiresAt, + z.E.MaxUses, z.E.UsedCount, z.E.RevokedAt, z.E.Gilt(jetzt))).ToList()); + }); + + // **Zurückziehen, nicht löschen.** Eine Zeile wegzuwerfen hiesse, + // hinterher nicht mehr zu wissen, dass es den Link je gab — und genau + // das will man wissen, wenn jemand Unerwartetes im Haushalt auftaucht. + verwalten.MapDelete("/{id:guid}", async ( + Guid id, FlaroContext db, TimeProvider uhr, CancellationToken ct) => + { + var einladung = await db.Einladungen.FirstOrDefaultAsync(e => e.Id == id, ct); + if (einladung is null) return Einwand.Fehlt("Diese Einladung gibt es nicht mehr."); + + einladung.RevokedAt ??= uhr.GetUtcNow(); + await db.SaveChangesAsync(ct); + return Results.NoContent(); + }); + + /* ---------- Was ohne Konto erreichbar sein muss ---------- */ + + app.MapGet("/api/v1/invites/{token}", async ( + string token, FlaroContext db, TimeProvider uhr, CancellationToken ct) => + { + var gefunden = await Suchen(db, token, ct); + if (Pruefen(gefunden, uhr.GetUtcNow()) is { } einwand) return einwand; + + var name = await db.Users.AsNoTracking() + .Where(u => u.Id == gefunden!.CreatedByUserId) + .Select(u => u.UserName).FirstOrDefaultAsync(ct) ?? ""; + + var servername = await db.Settings.AsNoTracking() + .Where(s => s.Key == SystemEndpoints.NameSchlüssel) + .Select(s => s.Value).FirstOrDefaultAsync(ct); + + return Results.Ok(new Einladungsschau( + string.IsNullOrWhiteSpace(servername) ? "Flaro" : servername, + name, gefunden!.ExpiresAt)); + }).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn); + + app.MapPost("/api/v1/invites/{token}/redeem", async ( + string token, EinloesenRequest anfrage, + FlaroContext db, TimeProvider uhr, CancellationToken ct) => + { + var name = anfrage.UserName?.Trim() ?? ""; + if (name.Length == 0 || (anfrage.Password?.Length ?? 0) < 8) + { + return Einwand.Bitte( + "Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."); + } + + var jetzt = uhr.GetUtcNow(); + var gefunden = await Suchen(db, token, ct); + if (Pruefen(gefunden, jetzt) is { } einwand) return einwand; + + // **Verbrauchen und anlegen gehören zusammen.** + // + // Zwei Leute, die gleichzeitig denselben einmaligen Link öffnen, + // läsen beide `UsedCount = 0` und schrieben beide `1` — und hätten + // beide ein Konto. Der Zähler wird deshalb mit einer Bedingung + // hochgesetzt, die die Gültigkeit noch einmal prüft; wer dabei + // keine Zeile trifft, war der Zweite. + // + // Und das Ganze in einer Transaktion: scheitert das Anlegen am + // Namen, rollt der Verbrauch mit zurück. Sonst wäre ein einmaliger + // Link an einem Tippfehler verbrannt. + await using var geschaeft = await db.Database.BeginTransactionAsync(ct); + + var verbraucht = await db.Einladungen + .Where(e => e.Id == gefunden!.Id + && e.RevokedAt == null + && e.ExpiresAt > jetzt + && (e.MaxUses == null || e.UsedCount < e.MaxUses)) + .ExecuteUpdateAsync(s => s.SetProperty(e => e.UsedCount, e => e.UsedCount + 1), ct); + + if (verbraucht != 1) + return Einwand.Bitte("Dieser Einladungslink wurde schon benutzt."); + + if (await db.Users.AnyAsync(u => u.UserName == name, ct)) + return Einwand.Belegt($"„{name}“ gibt es schon. Nimm einen anderen."); + + db.Users.Add(new User + { + UserName = name, + PasswordHash = PasswordHasher.Hash(anfrage.Password!), + IsAdministrator = false, + }); + + await db.SaveChangesAsync(ct); + await geschaeft.CommitAsync(ct); + return Results.NoContent(); + }).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn); + } + + private static Task Suchen(FlaroContext db, string token, CancellationToken ct) => + db.Einladungen.FirstOrDefaultAsync( + e => e.TokenFingerprint == Secrets.Fingerprint(token ?? ""), ct); + + /// Der Grund, warum ein Link nicht gilt — oder null. + /// + /// + /// Unterschiedliche Sätze, mit Absicht. Alles in ein „gilt nicht" + /// zusammenzufassen wäre verschwiegener, aber hier ist Verschwiegenheit + /// am falschen Platz: wer einen Link bekommen hat und abgewiesen wird, + /// muss wissen, ob er zu spät ist oder ob jemand ihn zurückgezogen hat — + /// sonst fragt er nach, und der Gastgeber rät mit. + /// + private static IResult? Pruefen(Einladung? einladung, DateTimeOffset jetzt) => einladung switch + { + null => Einwand.Fehlt("Diesen Einladungslink gibt es nicht."), + { RevokedAt: not null } => Einwand.Bitte("Dieser Einladungslink wurde zurückgezogen."), + var e when jetzt >= e.ExpiresAt => Einwand.Bitte("Dieser Einladungslink ist abgelaufen."), + var e when e.MaxUses is { } h && e.UsedCount >= h => + Einwand.Bitte("Dieser Einladungslink wurde schon benutzt."), + _ => null, + }; + + private static Guid? Kennung(ClaimsPrincipal wer) => + Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null; +} diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index 8ab5824..5016467 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -272,6 +272,7 @@ app.MapArtwork(); app.MapProfil(); app.MapFreigabe(); app.MapMeldungen(); +app.MapEinladungen(); // **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile // fängt der Rückfall darunter jeden unbekannten Pfad ab und liefert die diff --git a/server/src/Flaro.Core/Auth/Einladung.cs b/server/src/Flaro.Core/Auth/Einladung.cs new file mode 100644 index 0000000..f75939a --- /dev/null +++ b/server/src/Flaro.Core/Auth/Einladung.cs @@ -0,0 +1,54 @@ +namespace Flaro.Core.Auth; + +/// Ein Link, mit dem sich jemand selbst ein Konto anlegt. +/// +/// +/// +/// Warum nicht der vorhandene . Der ist ein +/// Gerätecode: fünf Minuten Leben, ein Gerätename daneben, und sein +/// ApprovedByUserId zeigt auf ein Konto, das es schon gibt — jemand, +/// der angemeldet ist, winkt ein zweites Gerät durch. Eine Einladung ist das +/// Gegenteil: erlaubt wird vorab, und wer sie einlöst, hat noch gar kein +/// Konto. Dieselbe Tabelle für beides hiesse, die Hälfte ihrer Spalten +/// wechselweise leer zu lassen. +/// +/// +/// Übernommen ist die Geheimnisdisziplin. Wie bei +/// liegt hier nur der Fingerabdruck; den Link +/// selbst bekommt man genau einmal zu sehen, beim Erstellen. Wer die +/// Datenbank liest, kann daraus keine Einladung bauen. +/// +/// +/// Keine Rolle. Wer eingeladen wird, ist gewöhnlicher Benutzer. Ein +/// Feld, über das ein Link Adminrechte verschenken könnte, gibt es nicht — +/// und damit auch keinen Weg, es versehentlich zu setzen. Administratoren +/// legt man von Hand an, und das ist selten genug. +/// +/// +public sealed class Einladung +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + + /// Siehe — nie der Link selbst. + public required string TokenFingerprint { get; init; } + + public required Guid CreatedByUserId { get; init; } + + public required DateTimeOffset CreatedAt { get; init; } + + public required DateTimeOffset ExpiresAt { get; init; } + + /// Wie oft er gilt. null heisst: unbegrenzt. + public int? MaxUses { get; init; } + + public int UsedCount { get; set; } + + /// Von Hand zurückgezogen — null heisst: gilt noch. + public DateTimeOffset? RevokedAt { get; set; } + + /// Ob sich damit jetzt noch ein Konto anlegen lässt. + public bool Gilt(DateTimeOffset jetzt) => + RevokedAt is null + && jetzt < ExpiresAt + && (MaxUses is not { } hoechstens || UsedCount < hoechstens); +} diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs index 79c6bfb..0d46b7e 100644 --- a/server/src/Flaro.Data/FlaroContext.cs +++ b/server/src/Flaro.Data/FlaroContext.cs @@ -55,6 +55,9 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon public DbSet Users => Set(); public DbSet DeviceTokens => Set(); public DbSet AuthCodes => Set(); + + /// Links, mit denen sich jemand selbst ein Konto anlegt. + public DbSet Einladungen => Set(); public DbSet Libraries => Set(); public DbSet Settings => Set(); @@ -129,6 +132,24 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon e.Property(x => x.DeviceId).HasMaxLength(128); }); + b.Entity(e => + { + e.HasKey(x => x.Id); + + // Gesucht wird nur über den Fingerabdruck — und eindeutig, damit + // zwei Links niemals dasselbe Konto füttern können. + e.HasIndex(x => x.TokenFingerprint).IsUnique(); + e.Property(x => x.TokenFingerprint).HasMaxLength(64); + + // Die Liste in der Verwaltung: die neuesten zuerst. + e.HasIndex(x => x.CreatedAt); + + // Wer einlädt und später gelöscht wird, nimmt seine offenen + // Einladungen mit. Ein Link ohne Absender ist keiner. + e.HasOne().WithMany().HasForeignKey(x => x.CreatedByUserId) + .OnDelete(DeleteBehavior.Cascade); + }); + b.Entity(e => { e.HasKey(x => x.Id); diff --git a/server/src/Flaro.Data/Migrations/20261007151411_Einladungen.Designer.cs b/server/src/Flaro.Data/Migrations/20261007151411_Einladungen.Designer.cs new file mode 100644 index 0000000..51603df --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261007151411_Einladungen.Designer.cs @@ -0,0 +1,1128 @@ +// +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("20261007151411_Einladungen")] + partial class Einladungen + { + /// + 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("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/20261007151411_Einladungen.cs b/server/src/Flaro.Data/Migrations/20261007151411_Einladungen.cs new file mode 100644 index 0000000..750d1f9 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261007151411_Einladungen.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Einladungen : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Einladungen", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TokenFingerprint = table.Column(type: "TEXT", maxLength: 64, nullable: false), + CreatedByUserId = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + ExpiresAt = table.Column(type: "INTEGER", nullable: false), + MaxUses = table.Column(type: "INTEGER", nullable: true), + UsedCount = table.Column(type: "INTEGER", nullable: false), + RevokedAt = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Einladungen", x => x.Id); + table.ForeignKey( + name: "FK_Einladungen_Users_CreatedByUserId", + column: x => x.CreatedByUserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Einladungen_CreatedAt", + table: "Einladungen", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_Einladungen_CreatedByUserId", + table: "Einladungen", + column: "CreatedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_Einladungen_TokenFingerprint", + table: "Einladungen", + column: "TokenFingerprint", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Einladungen"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index 316a596..789f8b7 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -108,6 +108,47 @@ namespace Flaro.Data.Migrations 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") @@ -920,6 +961,15 @@ namespace Flaro.Data.Migrations .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) diff --git a/server/tests/Flaro.Api.Tests/EinladungTests.cs b/server/tests/Flaro.Api.Tests/EinladungTests.cs new file mode 100644 index 0000000..c58e6f7 --- /dev/null +++ b/server/tests/Flaro.Api.Tests/EinladungTests.cs @@ -0,0 +1,263 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using Flaro.Api.Endpoints; + +namespace Flaro.Api.Tests; + +/// Einladen statt ein Konto für jemanden erfinden. +/// +/// +/// Der Test, der hier wirklich zählt, ist der mit den zwei gleichzeitigen +/// Einlösungen. Ein einmaliger Link, zweimal im selben Augenblick +/// geöffnet, ist der einzige Fall, den man von Hand nie findet — und der +/// einzige, bei dem ein falscher Entwurf zwei Konten statt einem anlegt. +/// +public class EinladungTests +{ + private static async Task Einladen( + HttpClient alsAdmin, int tage = 7, int? wieOft = 1) + { + var antwort = await alsAdmin.PostAsJsonAsync("/api/v1/invites", + new EinladungRequest(tage, wieOft)); + antwort.EnsureSuccessStatusCode(); + return (await antwort.Content.ReadFromJsonAsync())!; + } + + private static Task Einloesen( + HttpClient ohne, string token, string name, string passwort = "einlangespasswort") => + ohne.PostAsJsonAsync($"/api/v1/invites/{token}/redeem", new EinloesenRequest(name, passwort)); + + [Test] + public async Task Wer_den_Link_hat_legt_sich_ein_Konto_an() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + var fremder = server.OhneGedaechtnis(); + var schau = await fremder.GetFromJsonAsync( + $"/api/v1/invites/{einladung.Token}"); + var angelegt = await Einloesen(fremder, einladung.Token, "nachbarin"); + + var konten = await alsAdmin.GetFromJsonAsync>("/api/v1/users"); + + Assert.Multiple(() => + { + Assert.That(schau!.InvitedBy, Is.EqualTo("guido")); + Assert.That(angelegt.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + Assert.That(konten!.Select(k => k.UserName), Does.Contain("nachbarin")); + }); + } + + [Test] + public async Task Wer_eingeladen_wird_ist_kein_Administrator() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + await Einloesen(server.OhneGedaechtnis(), einladung.Token, "nachbarin"); + + var anmeldung = await server.SignIn("nachbarin", "einlangespasswort", "Prüfgerät", "p1"); + var alsGast = server.AlsAngemeldeter(anmeldung.Token); + var verwaltung = await alsGast.GetAsync("/api/v1/invites"); + + Assert.Multiple(() => + { + Assert.That(anmeldung.User.IsAdministrator, Is.False); + Assert.That(verwaltung.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + }); + } + + [Test] + public async Task Ein_eingeloester_Link_gilt_nicht_zweimal() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + await Einloesen(server.OhneGedaechtnis(), einladung.Token, "erste"); + var zweite = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zweite"); + + Assert.That(zweite.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(await zweite.Content.ReadAsStringAsync(), Does.Contain("schon benutzt")); + } + + /// Zwei im selben Augenblick — und trotzdem nur ein Konto. + [Test] + public async Task Zwei_gleichzeitige_Einloesungen_ergeben_ein_Konto() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + var beide = await Task.WhenAll( + Einloesen(server.OhneGedaechtnis(), einladung.Token, "eine"), + Einloesen(server.OhneGedaechtnis(), einladung.Token, "andere")); + + var konten = await alsAdmin.GetFromJsonAsync>("/api/v1/users"); + var dazu = konten!.Count(k => k.UserName is "eine" or "andere"); + + Assert.Multiple(() => + { + Assert.That(beide.Count(a => a.IsSuccessStatusCode), Is.EqualTo(1), + "Genau eine Einlösung darf durchkommen."); + Assert.That(dazu, Is.EqualTo(1), "Und genau ein Konto darf entstanden sein."); + }); + } + + [Test] + public async Task Mehrfachlinks_gelten_so_oft_wie_angegeben() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin, wieOft: 2); + + var eine = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "eine"); + var zwei = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zwei"); + var drei = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "drei"); + + var liste = await alsAdmin.GetFromJsonAsync>("/api/v1/invites"); + + Assert.Multiple(() => + { + Assert.That(eine.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + Assert.That(zwei.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + Assert.That(drei.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(liste![0].UsedCount, Is.EqualTo(2)); + Assert.That(liste[0].Valid, Is.False); + }); + } + + [Test] + public async Task Ein_zurueckgezogener_Link_wird_abgewiesen() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + (await alsAdmin.DeleteAsync($"/api/v1/invites/{einladung.Id}")).EnsureSuccessStatusCode(); + + var versuch = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zuspaet"); + var schau = await server.OhneGedaechtnis().GetAsync($"/api/v1/invites/{einladung.Token}"); + + Assert.Multiple(() => + { + Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(schau.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + }); + Assert.That(await versuch.Content.ReadAsStringAsync(), Does.Contain("zurückgezogen")); + } + + [Test] + public async Task Ein_abgelaufener_Link_wird_abgewiesen() + { + var uhr = new Stelluhr(DateTimeOffset.UtcNow); + using var server = new FlaroServer(dienste => dienste.AddSingleton(uhr)); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin, tage: 1); + + uhr.Weiter(TimeSpan.FromDays(2)); + + var versuch = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zuspaet"); + + Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(await versuch.Content.ReadAsStringAsync(), Does.Contain("abgelaufen")); + } + + [Test] + public async Task Einen_Link_den_es_nicht_gibt_kennt_niemand() + { + using var server = new FlaroServer(); + await server.SetUpAndSignIn(); + + var versuch = await Einloesen(server.OhneGedaechtnis(), "ausgedacht", "wer"); + var schau = await server.OhneGedaechtnis().GetAsync("/api/v1/invites/ausgedacht"); + + Assert.Multiple(() => + { + Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That(schau.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + }); + } + + [Test] + public async Task Einladen_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 erstellen = await gast.PostAsJsonAsync("/api/v1/invites", new EinladungRequest()); + var lesen = await gast.GetAsync("/api/v1/invites"); + + Assert.Multiple(() => + { + Assert.That(erstellen.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + Assert.That(lesen.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + }); + } + + /// Der Link steht genau einmal da — danach nicht einmal mehr dem Admin. + [Test] + public async Task Der_Token_steht_nur_im_Erstellen_nicht_in_der_Liste() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + var roh = await (await alsAdmin.GetAsync("/api/v1/invites")).Content.ReadAsStringAsync(); + + Assert.That(einladung.Token, Is.Not.Empty); + Assert.That(roh, Does.Not.Contain(einladung.Token), + "Gespeichert ist nur der Fingerabdruck — aus der Liste darf kein Link zu bauen sein."); + } + + /// Ein Tippfehler im Namen darf den Link nicht verbrennen. + [Test] + public async Task Ein_vergebener_Name_verbraucht_die_Einladung_nicht() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + var belegt = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "guido"); + var danach = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "nachbarin"); + + Assert.Multiple(() => + { + Assert.That(belegt.StatusCode, Is.EqualTo(HttpStatusCode.Conflict)); + Assert.That(danach.StatusCode, Is.EqualTo(HttpStatusCode.NoContent), + "Der Link muss nach dem Fehlgriff noch gelten."); + }); + } + + [Test] + public async Task Ohne_langes_Passwort_geht_es_nicht() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var einladung = await Einladen(alsAdmin); + + var kurz = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "wer", "1234"); + var leer = await Einloesen(server.OhneGedaechtnis(), einladung.Token, " "); + + Assert.Multiple(() => + { + Assert.That(kurz.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(leer.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + }); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 9491f75..1a7d4e5 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -1081,6 +1081,48 @@ export const benutzerAnlegen = (name: string, passwort: string, admin: boolean) body: JSON.stringify({ userName: name, password: passwort, isAdministrator: admin }), }); +/* ---------- Einladungen ---------- */ + +export type Einladung = { + id: string; + createdBy: string; + createdAt: string; + expiresAt: string; + /** `null` heisst: unbegrenzt oft. */ + maxUses: number | null; + usedCount: number; + revokedAt: string | null; + /** Ob sich damit jetzt noch ein Konto anlegen lässt. */ + valid: boolean; +}; + +/** Was die Einladungsseite zeigt, bevor jemand etwas eintippt. */ +export type Einladungsschau = { serverName: string; invitedBy: string; expiresAt: string }; + +/** Legt einen Einladungslink an. Der Token steht nur in dieser einen Antwort. */ +export const einladungAnlegen = (tage: number, wieOft: number | null) => + anfrage<{ id: string; token: string; expiresAt: string; maxUses: number | null }>('/invites', { + method: 'POST', + body: JSON.stringify({ days: tage, maxUses: wieOft }), + }); + +export const einladungen = () => anfrage('/invites'); + +/** Zieht ihn zurück. Die Zeile bleibt — man will wissen, dass es ihn gab. */ +export const einladungZurueckziehen = (id: string) => + anfrage(`/invites/${id}`, { method: 'DELETE' }); + +/** Was hinter einem Link steckt — ohne Anmeldung abrufbar. */ +export const einladungAnsehen = (token: string) => + anfrage(`/invites/${encodeURIComponent(token)}`); + +/** Legt das Konto an. Angemeldet wird danach gewöhnlich über `anmelden`. */ +export const einladungEinloesen = (token: string, name: string, passwort: string) => + anfrage(`/invites/${encodeURIComponent(token)}/redeem`, { + method: 'POST', + body: JSON.stringify({ userName: name, password: passwort }), + }); + export const metadatenEinstellung = () => anfrage<{ hasApiKey: boolean; language: string; usingProjectKey: boolean }>('/settings/metadata'); diff --git a/web/app/src/main.ts b/web/app/src/main.ts index 7c1d33c..c484a7a 100644 --- a/web/app/src/main.ts +++ b/web/app/src/main.ts @@ -6,6 +6,7 @@ import { } from './api'; import { icons } from './icons'; import { einrichtung } from './views/setup'; +import { einladungsseite } from './views/einladung'; import { einhaengen, ablegen, vergessen } from './ansichten'; import { anmeldung } from './views/login'; import { startseite } from './views/home'; @@ -38,6 +39,25 @@ async function hochfahren(): Promise { serverName = auskunft.serverName ?? 'Flaro'; if (auskunft.needsSetup) { zeigeAnmeldung(einrichtung(() => location.reload())); return; } + + // **Die Einladung muss hierher, nicht in den Router.** Beide Zweige + // ringsum kehren zurück, bevor `zeichnen()` je läuft — ein Ausgeloggter + // mit Einladungslink sähe sonst schlicht die Anmeldekarte, und nach dem + // Anmelden landete er auf der Startseite, ohne je erfahren zu haben, + // wozu er eingeladen war. + // + // Nach dem Einrichten, damit ein nagelneuer Server zuerst seinen + // Administrator bekommt; vor der Anmeldung, weil genau die ja fehlt. + const [erstes, zweites] = location.hash.replace(/^#\/?/, '').split('/'); + if (erstes === 'einladung' && zweites) { + zeigeAnmeldung(einladungsseite(decodeURIComponent(zweites), () => { + // Der Hash muss weg, sonst landet das Neuladen wieder hier. + location.hash = '#/'; + location.reload(); + })); + return; + } + if (!angemeldet()) { zeigeAnmeldung(anmeldung(() => location.reload())); return; } anmeldeflaeche.classList.add('hidden'); diff --git a/web/app/src/views/einladung.ts b/web/app/src/views/einladung.ts new file mode 100644 index 0000000..9058cff --- /dev/null +++ b/web/app/src/views/einladung.ts @@ -0,0 +1,134 @@ +import { + einladungAnsehen, einladungEinloesen, anmelden, abmelden, sitzung, angemeldet, + type Einladungsschau, +} from '../api'; +import { knopf, feld, fehlerzeile, meldung, esc } from '../util'; + +/** Die Seite hinter einem Einladungslink. + * + * **Die einzige Stelle neben dem Einrichten, an der jemand ohne Anmeldung + * etwas anlegt** — und sie verschliesst sich selbst: der Link gilt begrenzt + * oft und begrenzt lange, und der Server prüft beides noch einmal. + * + * **Name und Passwort gibt sich der Eingeladene selbst.** Das ist der ganze + * Sinn: ein Konto von Hand anzulegen hiesse, sich ein Passwort für einen + * anderen auszudenken und es ihm über denselben Kanal durchzusagen, über den + * er auch den Link bekommen hätte. + */ +export function einladungsseite(token: string, fertig: () => void): HTMLElement { + const karte = document.createElement('div'); + karte.className = 'login-card'; + karte.innerHTML = '
'; + + // **Wer schon angemeldet ist, bekommt kein Formular.** Ein zweites Konto + // für denselben Menschen ist fast nie gemeint; viel wahrscheinlicher ist + // der Gastgeber, der seinen eigenen Link anklickt, um zu sehen, ob er geht. + if (angemeldet()) { + karte.innerHTML = ''; + const h = document.createElement('h1'); + h.textContent = 'Einladung'; + const satz = document.createElement('p'); + satz.className = 'hinweis'; + satz.innerHTML = `Du bist schon als ${esc(sitzung.wer?.userName ?? '')} ` + + 'angemeldet. Für ein neues Konto musst du dich zuerst abmelden.'; + + const weiter = knopf('Weiter zu Flaro', 'btn btn-primary'); + weiter.onclick = fertig; + + const wechseln = knopf('Abmelden und Konto anlegen', 'btn'); + wechseln.onclick = () => { abmelden(); location.reload(); }; + + const reihe = document.createElement('div'); + reihe.className = 'knopfreihe'; + reihe.append(weiter, wechseln); + karte.append(h, satz, reihe); + return karte; + } + + void laden(); + return karte; + + async function laden(): Promise { + let schau: Einladungsschau; + try { + schau = await einladungAnsehen(token); + } catch (fehler) { + // **Der Grund steht da.** „Gilt nicht" wäre verschwiegener, aber wer + // einen Link bekommen hat und abgewiesen wird, muss wissen, ob er zu + // spät ist oder ob jemand ihn zurückgezogen hat — sonst fragt er nach, + // und der Gastgeber rät mit. + karte.innerHTML = '

Einladung

'; + karte.appendChild(fehlerzeile(meldung(fehler))); + const zurueck = knopf('Zur Anmeldung', 'btn'); + zurueck.onclick = fertig; + karte.appendChild(zurueck); + return; + } + + karte.innerHTML = ''; + + const titel = document.createElement('h1'); + titel.textContent = `Willkommen bei ${schau.serverName}`; + + const text = document.createElement('p'); + text.className = 'hinweis'; + const bis = new Date(schau.expiresAt) + .toLocaleDateString('de-CH', { dateStyle: 'medium' }); + text.innerHTML = `${esc(schau.invitedBy)} hat dich eingeladen. ` + + `Wähle dir Namen und Passwort — der Link gilt noch bis zum ${esc(bis)}.`; + + const name = feld('Benutzername'); + name.autocomplete = 'username'; + const passwort = feld('Passwort, mindestens 8 Zeichen', 'password'); + passwort.autocomplete = 'new-password'; + + const bleiben = document.createElement('label'); + bleiben.className = 'bleiben'; + const haken = document.createElement('input'); + haken.type = 'checkbox'; + haken.checked = true; + bleiben.append(haken, document.createTextNode(' Angemeldet bleiben')); + + const los = knopf('Konto anlegen', 'btn btn-primary'); + + karte.append(titel, text, name, passwort, bleiben, los); + + async function absenden(): Promise { + karte.querySelector('.fehler')?.remove(); + + // Hier schon, damit der Link nicht für einen Tippfehler hinausgeht. + if (name.value.trim().length === 0 || passwort.value.length < 8) { + karte.appendChild(fehlerzeile( + 'Benutzername darf nicht leer sein, das Passwort braucht mindestens acht Zeichen.')); + return; + } + + los.disabled = true; + try { + await einladungEinloesen(token, name.value.trim(), passwort.value); + } catch (fehler) { + karte.appendChild(fehlerzeile(meldung(fehler))); + los.disabled = false; + return; + } + + // **Angelegt ist nicht angemeldet.** Scheitert dieser zweite Schritt, + // steht das Konto trotzdem — dann ist der Weg die gewöhnliche + // Anmeldung, und genau das muss dastehen. + try { + await anmelden(name.value.trim(), passwort.value, haken.checked); + fertig(); + } catch { + karte.appendChild(fehlerzeile( + 'Das Konto steht, aber die Anmeldung hat nicht geklappt. ' + + 'Melde dich mit deinem neuen Namen und Passwort an.')); + los.disabled = false; + } + } + + los.onclick = () => void absenden(); + name.onkeydown = e => { if (e.key === 'Enter') passwort.focus(); }; + passwort.onkeydown = e => { if (e.key === 'Enter') void absenden(); }; + setTimeout(() => name.focus(), 50); + } +} diff --git a/web/app/src/views/einstellungen/benutzer.ts b/web/app/src/views/einstellungen/benutzer.ts index e62feea..6c2a220 100644 --- a/web/app/src/views/einstellungen/benutzer.ts +++ b/web/app/src/views/einstellungen/benutzer.ts @@ -1,5 +1,8 @@ -import { benutzer, benutzerAnlegen } from '../../api'; -import { esc, knopf, fehlerzeile, meldung } from '../../util'; +import { + benutzer, benutzerAnlegen, + einladungAnlegen, einladungen, einladungZurueckziehen, type Einladung, +} from '../../api'; +import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../../util'; import { dialog } from '../../dialog'; import { abschnitt } from './teile'; @@ -38,13 +41,130 @@ export function benutzerbereich(): HTMLElement { }, }); - bereich.appendChild(abschnitt('Benutzer', [anlegen])); + // **Einladen statt ein Konto erfinden.** Ein Konto von Hand anzulegen + // heisst, sich für jemand anderen ein Passwort auszudenken und es ihm + // durchzusagen — über denselben Kanal, über den auch der Link ginge. + const einladen = knopf('Einladen…', 'btn'); + einladen.onclick = () => dialog({ + titel: 'Jemanden einladen', + text: 'Wer den Link öffnet, wählt Benutzername und Passwort selbst und ist ' + + 'danach angemeldet. Administrator wird dabei niemand.', + felder: [ + { + name: 'tage', beschriftung: 'Gültig', + auswahl: [ + { wert: '1', text: 'Einen Tag' }, + { wert: '7', text: 'Eine Woche' }, + { wert: '30', text: 'Einen Monat' }, + ], + wert: '7', + }, + { + name: 'wieoft', beschriftung: 'Benutzbar', + auswahl: [ + { wert: '1', text: 'Einmal' }, + { wert: '5', text: 'Fünfmal' }, + { wert: '', text: 'Unbegrenzt' }, + ], + wert: '1', + hinweis: 'Ein mehrfacher Link ist bequem für eine Familie — und wer ihn ' + + 'weitergibt, lädt jemanden ein, den du nicht gemeint hast.', + }, + ], + bestaetigen: 'Link erstellen', + tun: async wert => { + const wieOft = wert('wieoft') === '' ? null : Number(wert('wieoft')); + const neu = await einladungAnlegen(Number(wert('tage')), wieOft); + await laden(); + linkZeigen(neu.token); + }, + }); + + bereich.appendChild(abschnitt('Benutzer', [einladen, anlegen])); const liste = document.createElement('div'); liste.className = 'einstellungen'; bereich.appendChild(liste); + + // **Bleibt weg, solange niemand eingeladen wurde.** Eine Überschrift über + // einer leeren Liste erklärt nichts; den Weg dorthin zeigt der Knopf oben. + const einladungskopf = abschnitt('Einladungen'); + einladungskopf.classList.add('hidden'); + const einladungsliste = document.createElement('div'); + einladungsliste.className = 'einstellungen hidden'; + bereich.append(einladungskopf, einladungsliste); + void laden(); + /** Der fertige Link, genau einmal. + * + * **Die Adresse baut der Client.** Der Server weiss nicht, unter welchem + * Namen er von aussen erreichbar ist; hinter einem Reverse Proxy rät er + * falsch, und ein falscher Einladungslink ist schlimmer als keiner. */ + function linkZeigen(token: string): void { + const adresse = `${location.origin}${location.pathname}#/einladung/${token}`; + + dialog({ + titel: 'Einladung fertig', + text: 'Schick diesen Link weiter. Er steht nur jetzt da — gespeichert ist ' + + 'nur sein Fingerabdruck, aus der Liste lässt er sich nicht noch ' + + 'einmal herstellen.', + felder: [{ name: 'link', beschriftung: 'Link', wert: adresse }], + bestaetigen: 'Kopieren', + tun: async () => { + try { + await navigator.clipboard.writeText(adresse); + hinweisZeigen('Link kopiert.'); + } catch { + // Ohne Zwischenablage — etwa ohne HTTPS — bleibt das Feld zum + // Markieren stehen, und das ist kein Fehler wert. + hinweisZeigen('Kopieren ging nicht. Der Link steht im Feld.', 'fehler'); + } + }, + }); + } + + /* ---------- Offene Einladungen ---------- */ + + function einladungszeile(e: Einladung): HTMLElement { + const d = document.createElement('div'); + d.className = 'einstellung'; + + const benutzt = e.maxUses === null + ? `${e.usedCount}× benutzt, unbegrenzt` + : `${e.usedCount} von ${e.maxUses} benutzt`; + const stand = e.revokedAt ? 'Zurückgezogen' + : e.valid ? `Gültig bis ${datum(e.expiresAt)}` + : 'Abgelaufen oder aufgebraucht'; + + const zeile = document.createElement('div'); + zeile.className = 'zeile'; + zeile.style.flex = '1'; + zeile.innerHTML = `${esc(stand)}` + + `${esc(`${benutzt} · von ${e.createdBy} · ${datum(e.createdAt)}`)}`; + + const reihe = document.createElement('div'); + reihe.className = 'knopfreihe'; + reihe.appendChild(zeile); + + if (!e.revokedAt) { + const weg = knopf('Zurückziehen', 'btn'); + weg.onclick = async () => { + weg.disabled = true; + try { await einladungZurueckziehen(e.id); await laden(); } + catch (fehler) { weg.disabled = false; d.appendChild(fehlerzeile(meldung(fehler))); } + }; + reihe.appendChild(weg); + } + + d.appendChild(reihe); + return d; + } + + function datum(wert: string): string { + return new Date(wert).toLocaleDateString('de-CH', { dateStyle: 'medium' }); + } + async function laden(): Promise { // **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.** // Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint — @@ -64,6 +184,14 @@ export function benutzerbereich(): HTMLElement { liste.innerHTML = ''; liste.appendChild(fehlerzeile(meldung(fehler))); } + + try { + const offene = await einladungen(); + einladungsliste.innerHTML = ''; + einladungskopf.classList.toggle('hidden', offene.length === 0); + einladungsliste.classList.toggle('hidden', offene.length === 0); + offene.forEach(e => einladungsliste.appendChild(einladungszeile(e))); + } catch { /* Ein Abschnitt, der nicht lädt, fehlt eben. */ } } return bereich;